Developed By Ken Key

PHP Best Practices in 2026: A Working Developer’s Checklist

By Ken Key Oct 19, 2024 6 min read Updated Oct 16, 2026

PHP Best Practices in 2026: A Working Developer’s Checklist
The short answer

Turn on declare(stricttypes=1) in every file, then type everything: properties, parameters, return values. Use enums, readonly, and constructor promotion. Run PHP 8.4, keep dependencies current, and let static analysis like PHPStan catch what tests miss. Strict types plus real types prevent most silent bugs I get hired to fix.

Part of the complete web development guide.

On this page

Most “PHP best practices” posts are a list of rules copied from 2014. Half of them are obsolete, and the other half ignore the fact that PHP 8.4 changed how a lot of us write code. Here’s what I actually do on projects in 2026, and why.

I’ve been writing PHP for a long time and maintaining sites that other people built badly. The advice below comes from fixing those sites, not from a style guide.

Turn on strict types. Every file.

This is the single highest-value line in any PHP file:

<?php
declare(strict_types=1);

Without it, PHP silently coerces types. A function that expects an int will happily accept the string "5" and you’ll never know. With it, you get a TypeError at the call site, which is exactly where you want to find the bug instead of three functions deep in a stack trace.

It has to be the first statement in the file, before any other code, and it applies per file. There’s no global switch. So either you put it everywhere or it does nothing. Pick everywhere.

Type everything you can, including properties

PHP 7.4 gave us typed properties. PHP 8.0 gave us union types and constructor promotion. PHP 8.1 added enums and readonly. PHP 8.2 added readonly classes and DNF types. There’s no reason in 2026 to be writing untyped code.

<?php
declare(strict_types=1);

namespace AppBilling;

use AppBillingCurrency;

final readonly class Invoice
{
 public function __construct(
 public string $id,
 public int $amountCents,
 public Currency $currency,
 public DateTimeImmutable $issuedAt,
 ) {}
}

Note amountCents as an integer. Never store money as a float. 0.1 + 0.2 does not equal 0.3 in binary floating point, and I have had to untangle the accounting mess that follows. Integers in the smallest unit, or a dedicated money library. That’s it.

The readonly keyword on the class means every property is immutable once set. Value objects like this are trivially safe to pass around because nobody can mutate them out from under you.

Stop using arrays as data structures

Passing associative arrays around is the most common source of bugs in the PHP codebases I get handed. You can’t tell what keys exist, typos fail silently, and your IDE can’t help you.

If a shape shows up in more than one place, make it a class. If it’s a fixed set of options, make it an enum:

<?php
declare(strict_types=1);

enum Currency: string
{
 case USD = 'USD';
 case EUR = 'EUR';
 case GBP = 'GBP';

 public function symbol(): string
 {
 return match($this) {
 self::USD => '$',
 self::EUR => '€',
 self::GBP => '£',
 };
 }
}

The match expression is exhaustive. If someone adds a case later and forgets to handle it, PHP throws an UnhandledMatchError immediately instead of returning null and blowing up somewhere unrelated. That’s the behavior you want.

Use a static analyzer, not just your eyes

PHPStan or Psalm. Pick one. Run it at a high level. This catches more real bugs than any test suite I’ve written, and it catches them before the code runs.

composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src --level=8

Level 8 is strict but achievable on a new codebase. On a legacy codebase, start at level 0, get it passing, then bump the level and fix the new errors. It’s incremental and it works. I’ve taken 10-year-old code from level 0 to level 6 over a few weeks of spare hours, and the number of latent null-pointer bugs it surfaced was not small.

Pair it with a code style tool so you stop arguing about formatting in review. PHP CS Fixer or Laravel Pint if you’re on Laravel. Let the machine decide, and spend review time on logic instead.

Dependency injection, but keep it boring

You don’t need a framework to do dependency injection. You need to stop calling new inside your classes and start passing things in.

<?php
declare(strict_types=1);

final class UserRegistration
{
 public function __construct(
 private readonly UserRepository $users,
 private readonly Mailer $mailer,
 private readonly Clock $clock,
 ) {}

 public function register(string $email): User
 {
 $user = User::create($email, $this->clock->now());
 $this->users->save($user);
 $this->mailer->sendWelcome($user);

 return $user;
 }
}

Three dependencies, all interfaces, all injected. This class is now testable without a database, without a mail server, and without mocking the current time (inject a clock, always). Note that I’m not using a service container here. For a lot of applications, manual wiring in a single bootstrap file is clearer and faster than a container with autowiring magic. Containers earn their keep on large apps. On small ones they’re a layer of indirection you’ll spend an afternoon debugging.

Composer discipline

  • Commit composer.lock for applications. Always. Not for libraries.
  • Run composer audit in CI. It checks your installed packages against known vulnerability databases and takes ten seconds.
  • Keep dependencies current. A codebase that’s two major versions behind on everything is a codebase you can’t upgrade later without a rewrite.
  • Use composer outdated --direct to see what you’re actually behind on, ignoring transitive noise.

Error handling: throw, don’t return false

Returning false to signal failure is a habit from PHP 4 that needs to die. The caller has to remember to check, and half the time they don’t.

<?php
declare(strict_types=1);

final class PaymentFailed extends RuntimeException {}

function charge(Card $card, int $amountCents): Receipt
{
 $result = $gateway->charge($card, $amountCents);

 if (!$result->ok) {
 throw new PaymentFailed($result->message);
 }

 return new Receipt($result->id);
}

Now the type system guarantees you get a Receipt or an exception. No nullable returns, no checking for false in five places. Catch it wherever it makes sense to handle it, and let it bubble otherwise.

Output escaping and prepared statements are not optional

This hasn’t changed and it never will. Two rules:

  • Use PDO or your ORM’s parameter binding. Never concatenate user input into SQL. Not even “just this once” for an admin page.
  • Escape on output, not on input. Use htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') or your template engine’s auto-escaping. Twig and Blade escape by default. Raw PHP does not.

Escaping on input is a losing game because you can never be sure where the data will end up. Escape at the point of rendering, every time.

Don’t chase performance you haven’t measured

PHP 8.4 is fast. Opcache is on by default in most modern setups. JIT exists and helps in narrow CPU-bound cases, which is not most web apps. The bottleneck in nearly every slow PHP site I’ve profiled is database queries in a loop, not the language.

Profile first. Xdebug’s profiler or a tool like Blackfire will tell you where the time actually goes. Guessing leads to caching things that shouldn’t be cached, which leads to stale data bugs, which are worse than slow pages.

One concrete thing worth doing: enable Opcache in production and set opcache.validate_timestamps=0 so PHP stops checking file modification times on every request. Just remember to clear the cache on deploy.

A note on newer PHP versions

If you’re still on PHP 7.4, you’re running an unsupported version with no security patches. Upgrading from 7.4 to 8.x is real work (the biggest offenders are old dynamic property usage and removed functions), but it’s work that gets harder every year you wait. PHP 8.4 is current, and 8.5 is on the horizon. Plan the upgrade.

Most of what I’ve listed above is the same set of practices I apply whether I’m doing custom web development for a small business on Long Island or cleaning up a legacy app for a client anywhere else. The location doesn’t change the fundamentals.

FAQ

Do I need a framework to follow these practices?

No. Strict types, typed properties, enums, static analysis, and dependency injection all work in plain PHP. Frameworks give you routing, templating, and a container out of the box, which saves time on larger apps, but none of the practices above depend on one.

Is PHP still worth using in 2026?

Yes, and it’s a reasonable choice for most web applications. It runs a huge share of the web, hosting is cheap and universal, PHP 8.4 is genuinely fast, and the tooling (Composer, PHPStan, Xdebug) is mature. The “PHP is bad” take is about 15 years out of date.

What’s the one thing I should fix first in a messy codebase?

Add declare(strict_types=1) to new files and run PHPStan at level 0. Those two changes surface the worst bugs with almost no risk of breaking anything, and they give you a baseline to improve from.

Have a project like this?

I do custom web development for Long Island businesses. If you need this done right, let’s talk.