Skip to content

PHP got good and nobody told you

If your mental image of PHP is from 2014, it is wrong in ways that would change your architecture. Here is what actually landed, and which parts change how you write code.

3 min read
Contents

I still meet developers whose entire impression of PHP was formed by a WordPress theme they inherited in 2013. Fair enough — I was there too. But the language has changed more in the last six years than in the fifteen before it, and a few of those changes are significant enough to alter how you design things, not just how you type them.

This is not a changelog. These are the features that changed my code.

Constructor promotion, and the death of boilerplate

Before:

class OrderService
{
    private OrderRepository $orders;
    private PaymentGateway $gateway;
    private LoggerInterface $logger;

    public function __construct(
        OrderRepository $orders,
        PaymentGateway $gateway,
        LoggerInterface $logger
    ) {
        $this->orders = $orders;
        $this->gateway = $gateway;
        $this->logger = $logger;
    }
}

After:

class OrderService
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentGateway $gateway,
        private LoggerInterface $logger,
    ) {}
}

Thirteen lines to four, and the name of each dependency appears once instead of three times. This sounds cosmetic. It is not — it lowered the cost of making a small class enough that I now make small classes, where before I would have quietly added another method to something large.

Enums, which fix a real bug class

String constants for status fields were always a category of bug pretending to be a convention. Enums close it:

enum OrderStatus: string
{
    case Draft = 'draft';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function isFinal(): bool
    {
        return in_array($this, [self::Shipped, self::Cancelled], true);
    }

    public function label(): string
    {
        return match ($this) {
            self::Draft => 'Awaiting payment',
            self::Paid => 'Paid, preparing',
            self::Shipped => 'On its way',
            self::Cancelled => 'Cancelled',
        };
    }
}

Two things worth noticing. Enums can have methods, so the behaviour that belongs to a status lives with the status instead of in a helper file. And match on an enum is exhaustive — add a fifth case and every match that does not handle it throws, immediately, rather than silently falling through.

In Laravel, cast the column and the whole thing becomes transparent:

protected function casts(): array
{
    return ['status' => OrderStatus::class];
}

$order->status->label() in a Blade template, and a typo is now a fatal error instead of a blank badge.

Readonly, and why immutability got easy

final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {}

    public function add(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException('Currency mismatch');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }
}

A value object that cannot be mutated after construction, in a dozen lines, with no getters. readonly on the whole class covers every property at once.

The practical effect: I now pass small value objects around where I used to pass arrays, because the cost of defining one dropped to almost nothing. Array-shaped data with implicit keys is one of the biggest sources of confusion in a PHP codebase, and this is the cure.

Named arguments at the call site

$report = generateReport(
    from: $start,
    to: $end,
    includeRefunds: true,
    groupBy: 'week',
);

The obvious benefit is that true stops being a mystery at the call site. The less obvious one is that named arguments let you skip optional parameters in the middle, which means you can add a new optional parameter to a function without the signature becoming a minefield.

Property hooks and asymmetric visibility

The newest of these, and the one that surprised me most:

class Invoice
{
    public private(set) OrderStatus $status = OrderStatus::Draft;

    public string $reference {
        get => sprintf('INV-%06d', $this->id);
    }

    public string $email {
        set (string $value) {
            $this->email = strtolower(trim($value));
        }
    }
}

private(set) gives you a property that anyone can read and only the class can write — the thing everyone was writing a getter for. Property hooks give you computed and validated properties without the __get magic that made older code impossible to navigate.

Together these remove most of the reason to write accessor methods at all.

The things that matter and nobody blogs about

Types are real now. Union types, intersection types, never, static returns, nullable everything. With a static analyser on level 8 you get an experience close to a typed language, in a codebase you can still deploy by copying a file. That combination is rarer than it sounds.

It is fast. PHP 8 is several times faster than PHP 5.6 on ordinary web workloads, and the JIT helps with compute-heavy code, which is not what most of us are doing anyway. The thing that actually made my applications fast was the opcache and preloading, not the JIT.

The tooling caught up. PHPStan and Psalm find real bugs. Rector upgrades your syntax automatically. Pest made writing tests genuinely pleasant. Composer has been better than npm for years and does not get credit for it.

What I would tell 2014 me

Not that PHP became a different language — it did not, and the warts are still there. array_map and array_filter still take their arguments in the opposite order, and they always will.

What changed is that the good version of PHP and the easy version of PHP are now the same version. Writing typed, immutable, well-structured PHP used to mean fighting the language. Now it is the path of least resistance, which is the only kind of improvement that actually changes what people ship.

Share