PHP Best Practices in 2026: 15 Rules for Modern PHP
Modern PHP best practices — supported versions, strict types, PER coding style, Composer autoloading, prepared statements, output escaping, password hashing and typed properties — each with why and a code example.
Modern PHP looks nothing like the PHP of a decade ago. PHP 8.x brought typed properties, enums, readonly, match, constructor promotion and a JIT — and the ecosystem standardized on Composer, PSR autoloading and battle-tested frameworks. Writing good PHP in 2026 is about using those tools and avoiding a handful of well-known security traps. Here are 15 PHP best practices, each with the reasoning and a code example.
1. Run a supported PHP version (8.4 preferred, 8.3 minimum)
End-of-life versions receive no security patches, so running one is an unpatched-vulnerability liability. As of mid-2026, PHP 8.3 and 8.4 are actively supported (8.1 is already EOL, 8.2 is security-only). Pin the version in composer.json so CI and teammates can’t silently drift to an unsupported runtime.
{
"require": { "php": ">=8.3" }
}
// PHP 8.3 -> active support to 2027-12-31 ✅
// PHP 8.4 -> active support to 2028-12-31 ✅ recommended
2. Declare strict_types=1 in every file
Without it, PHP silently coerces types ("5 apples" becomes 5), hiding bugs; strict mode makes type mismatches throw a TypeError at the boundary. The declaration must be the very first statement in the file, and it affects the *calling* file, not the callee.
<?php
declare(strict_types=1); // MUST be the first statement
function add(int $a, int $b): int {
return $a + $b;
}
add(1, "2"); // ✅ TypeError instead of silently returning 3
3. Follow the PER Coding Style and enforce it automatically
A single, tool-enforced style removes bikeshedding from reviews and keeps diffs clean. PER-CS is the current FIG-maintained style (PSR-12 is its frozen predecessor). Don’t hand-format — wire PHP-CS-Fixer or PHP_CodeSniffer into a pre-commit hook and CI so style is never a manual review comment.
composer require --dev friendsofphp/php-cs-fixer
vendor/bin/php-cs-fixer fix --dry-run --diff # fails CI on violations
4. Autoload with Composer + PSR-4; never hand-write require
PSR-4 maps namespaces to directories so classes load on demand, and Composer manages the autoloader — no fragile manual include chains. Run composer dump-autoload -o (optimized classmap) for production, since the default dev autoloader does filesystem lookups on every class.
{
"autoload": { "psr-4": { "App\\": "src/" } }
}
// src/Service/Mailer.php -> class App\Service\Mailer
// require __DIR__ . "/vendor/autoload.php";
5. Always use parameterized queries (PDO); never concatenate SQL
String-built SQL is the classic SQL-injection vector. Prepared statements send the query structure and the data separately, so user input can never alter the query. Configure PDO to throw on error and use native (not emulated) prepares. Table and column names can’t be bound — validate them against an allowlist instead.
// ❌ NEVER
$db->query("SELECT * FROM users WHERE email = '$email'");
// ✅ prepared statement with bound parameters
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(["email" => $email]);
$user = $stmt->fetch();
// configure PDO once:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
6. Never trust user input: validate on input, escape on output
Untrusted input drives SQL injection, XSS and path traversal. Validate and normalize at the boundary, and escape at the point of output for the correct context (HTML, attribute, JS, URL). "Escaping" is context-specific — htmlspecialchars is for HTML, not for JS or SQL.
$email = filter_input(INPUT_POST, "email", FILTER_VALIDATE_EMAIL);
if ($email === false) { /* reject */ }
// escape on output to prevent XSS
echo htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
7. Hash passwords with password_hash() and password_verify()
These use a strong, salted, adaptive algorithm (bcrypt by default, or Argon2id) and handle salting for you — never use md5/sha1 or roll your own. Store the full hash in a column of at least 255 characters, and call password_needs_rehash() on login to transparently upgrade old hashes.
$hash = password_hash($plaintext, PASSWORD_DEFAULT); // bcrypt; future-proof
if (password_verify($plaintext, $hash)) {
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$hash = password_hash($plaintext, PASSWORD_DEFAULT); // upgrade
}
}
8. Store secrets in the environment, never in code or VCS
Hardcoded credentials leak through git history and code sharing. Environment config keeps secrets out of the repo and lets each environment differ. Add .env to .gitignore and commit only a .env.example; in production prefer real environment variables or a secrets manager over shipping a .env file.
$dbPassword = $_ENV["DB_PASSWORD"]
?? throw new RuntimeException("DB_PASSWORD not set");
// .env (git-ignored)
// DB_PASSWORD=super-secret
9. Use type declarations everywhere
Types are executable documentation, catch errors early, and power static analysis and IDE tooling — PHP 8 supports union types, nullable types and never/static returns. Add void/never where nothing is returned so callers can’t depend on a return value, and run PHPStan or Psalm at a high level to enforce type correctness.
final class Order {
public function __construct(
private readonly int $id,
private readonly Money $total,
) {}
public function total(): Money { return $this->total; }
public function fail(): never { throw new OrderException(); }
}
10. Use modern 8.x features: enums, readonly, match, promotion
These eliminate boilerplate and encode invariants in the type system — enums replace magic constants, readonly enforces immutability, and match uses strict comparison and throws on an unhandled case. That strictness is a feature: don’t blindly port loose switch logic to match.
enum Status: string {
case Active = "active";
case Archived = "archived";
public function label(): string {
return match ($this) {
Status::Active => "Active",
Status::Archived => "Archived",
};
}
}
11. Prefer immutability; return new instances instead of mutating
Immutable objects are easier to reason about and prevent action-at-a-distance bugs; PHP 8.1 readonly properties (and 8.2 readonly classes) make this ergonomic. Note readonly prevents reassignment of the property, but a mutable object it holds can still change internally — use readonly value objects all the way down for true immutability.
final readonly class Money {
public function __construct(public int $cents, public string $currency) {}
public function add(Money $other): self { // returns a new instance
return new self($this->cents + $other->cents, $this->currency);
}
}
12. Handle failures with typed exceptions; turn display_errors off in prod
Exceptions propagate failure cleanly with context, while leaking errors or stack traces to users exposes paths, queries and versions to attackers. Keep error_reporting = E_ALL even in production — log everything, just never *display* it — and catch specific exception types rather than bare \Throwable.
// production php.ini
// display_errors = Off
// log_errors = On
// error_reporting = E_ALL
try {
$repo->save($order);
} catch (PersistenceException $e) {
$logger->error("save failed", ["exception" => $e]);
throw new DomainException("Could not place order", previous: $e);
}
13. Never use the @ error-suppression operator
@ hides errors — including fatal ones — making bugs invisible and debugging miserable, with a runtime cost. Handle the condition explicitly instead. Prefer the exception-throwing alternative or a proper try/catch over reaching for @.
// ❌ swallows the failure silently
$data = @file_get_contents($path);
// ✅ check and handle
$data = file_get_contents($path);
if ($data === false) {
throw new RuntimeException("Unable to read {$path}");
}
14. Use namespaces; drop legacy syntax (short tags, mysql_*)
Namespaces prevent name collisions and are required for PSR-4. Short open tags (<?) are non-portable, and the old mysql_* extension was removed in PHP 7. In pure-PHP class files, omit the closing ?> tag entirely to avoid accidental trailing whitespace that breaks headers.
<?php // ✅ always the full tag, never <?
declare(strict_types=1);
namespace App\Billing;
use App\Support\Money; // import, don't fully-qualify inline
15. Write tests (PHPUnit or Pest) and use a maintained framework
Tests catch regressions, enable confident refactoring and document intended behavior — Pest is a modern, expressive layer over PHPUnit. And for real applications, a maintained framework (Laravel or Symfony) gives you routing, DI, an ORM, validation, CSRF protection and security defaults that are battle-tested. Lean on the framework’s security features rather than bypassing them.
// Pest
it("adds money of the same currency", function () {
$sum = (new Money(100, "USD"))->add(new Money(50, "USD"));
expect($sum->cents)->toBe(150);
});
PHP best practices checklist
- Run a supported version (8.3+); declare
strict_types=1at the top of every file. - Follow PER coding style (enforced by PHP-CS-Fixer); autoload with Composer + PSR-4.
- Always use PDO prepared statements; validate input and escape output for XSS.
- Hash passwords with
password_hash; keep secrets in the environment. - Use type declarations, enums,
readonlyandmatch; prefer immutability. - Catch specific exceptions and hide errors from users; never use
@. - Use namespaces, drop legacy syntax, write tests, and lean on a maintained framework.
Frequently asked questions
Which PHP version should I use in 2026?
composer.json so your environment can’t silently drift to an unsupported runtime.Is PSR-12 still the PHP coding standard?
match, attributes and constructor promotion. Present PSR-12 as the base and PER-CS as the standard to follow, enforced automatically with PHP-CS-Fixer.How do I prevent SQL injection in PHP?
ERRMODE_EXCEPTION and disable emulated prepares.How should I store passwords in PHP?
password_hash($plaintext, PASSWORD_DEFAULT) to create a salted bcrypt (or Argon2id) hash, and password_verify() to check it. Never use md5 or sha1. Store the full hash in a 255-char column, and call password_needs_rehash() on login to upgrade old hashes transparently.Run PHP in the browser — no local server needed — with the XCODX online compiler. Paste a script and test strict_types, enums, match and PDO logic instantly. Choosing a backend stack? See Node.js vs PHP.