Blog

Blog · Best Practices

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.

XCODX Team · 7 min read

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=1 at 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, readonly and match; 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?
Use PHP 8.4 for new projects (supported into 2028) or 8.3 as a safe minimum. Avoid 8.1 (already end-of-life) and treat 8.2 as security-only. Pin the minimum version in composer.json so your environment can’t silently drift to an unsupported runtime.
Is PSR-12 still the PHP coding standard?
It has been superseded. PHP-FIG’s current style is the PER Coding Style (PER-CS), which builds on PSR-12 and adds rules for enums, 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?
Always use parameterized queries via PDO (or your framework’s query builder) — send the SQL structure and the data separately with bound parameters, and never concatenate user input into the query string. Configure PDO with ERRMODE_EXCEPTION and disable emulated prepares.
How should I store passwords in PHP?
Use 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.