Typed properties and the constructor that got shorter

PHP has had types on parameters since 5.0 and on return values since 7.0, and nothing at all on the state in between. A property documented as int could hold a string, and the only thing checking was a static analyser reading a comment. 7.4 arrived on 28 November and closed the gap, along with the syntax change that removes the most tedious thing about closures.

The symptom

final class Order
{
    /** @var int */                       private $id;
    /** @var DateTimeImmutable|null */    private $shippedAt;
    /** @var string */                    private $currency = 'GBP';
}

// and the bug the docblock cannot prevent
$order->setId('91204');   // a string. the property says int. nothing stops it.
$order->total();          // Unsupported operand types, four layers away

Six lines of docblock describing three properties, none of it enforced by anything at runtime. The failure lands wherever the value is finally used, which is usually not where it was assigned — and the error message is about arithmetic rather than about a type.

Why it happens

Properties were left out of the type system for a genuinely hard reason: a typed property with no default has no valid value at construction time, and PHP had no way to represent that. null is not it — a property typed int cannot be null — so the language needed a new state, and adding a state to the object model is not a small change.

The fix

The declaration, and the state that is new

final class Order
{
    private int $id;
    private ?DateTimeImmutable $shippedAt = null;
    private string $currency = 'GBP';
}

$order = new Order();
$order->id;
// Error: Typed property Order::$id must not be accessed
//        before initialization

That error is a feature and it will surprise people, because an untyped property read before assignment returned null with a notice. The distinction between uninitialised and null is real and useful: it separates “no value yet” from “explicitly no value”, and it means a constructor that fails partway leaves an object that throws rather than one quietly reporting null.

Adding a nullable type with a null default is the escape hatch, and reaching for it on every property throws the benefit away. The rule that works is: nullable when null is a legitimate value in the domain, and uninitialised otherwise.

What it does to a constructor

// before: the type appears three times per property
final class Money
{
    /** @var int */
    private $cents;

    /** @param int $cents */
    public function __construct($cents) { $this->cents = $cents; }
}

// after: once
final class Money
{
    private int $cents;
    private string $currency;

    public function __construct(int $cents, string $currency)
    {
        $this->cents    = $cents;
        $this->currency = $currency;
    }
}

The constructor still assigns each property by hand — promotion is 8.0 — so this removes the docblocks rather than the assignments. On a codebase of value objects that is still a large deletion, and the important half is that the remaining type is checked rather than documented.

Static analysers get considerably more to work with, which is the second-order benefit and is worth more than the syntax. PHPStan on the same code at the same level found forty additional real errors after the properties were typed, because it could finally follow what was in them.

Arrow functions, and the use() clause that goes away

// before
$filtered = array_filter($rows, function ($row) use ($min, $currency) {
    return $row->total >= $min && $row->currency === $currency;
});

// 7.4
$filtered = array_filter($rows,
    fn($row) => $row->total >= $min && $row->currency === $currency);

The capture is automatic and by value, so an arrow function cannot modify the outer scope — which removes a whole category of accidental mutation and is why there is no by-reference form. The body is one expression, so anything with a statement stays a closure, and that limit is what keeps the syntax from being used where a named method would read better.

They nest, and a nested arrow function captures through both scopes, which is occasionally exactly what a small pipeline of collection operations wants and is otherwise a readability trap.

The rest of the release, briefly

// null coalescing assignment — right side evaluated only on null
$options['timeout'] ??= 30;
return $this->client ??= new Client($this->config);

// spread in an array literal — INTEGER KEYS ONLY until 8.1
$all = [...$defaults, ...$overrides];

// covariant returns — a child may narrow
abstract class Repository { abstract public function find(int $id): Entity; }
final class Orders extends Repository {
    public function find(int $id): Order { /* ... */ }
}

The lazy-initialisation form of ??= is the one worth adopting immediately, because a plain ?? assignment constructs the object every time and throws it away. The spread is less useful than it first appears — string keys are a fatal until 8.1, and most PHP arrays being merged are associative.

Covariant returns are the change that unblocks an interface that could not previously be written: a factory promising that each implementation returns its own concrete type. The callers get the specific type, the downcast and its instanceof disappear, and the analyser can see it.

Migrating an existing codebase

$ vendor/bin/rector process src --set php74 --dry-run

# mechanical:  /** @var int */ private $x   →   private int $x
#              function () use ($a) { ... }  →   fn() => ...
#
# NOT mechanical, and it should not try:
#   a property whose docblock is wrong
#   a property assigned in three places with three types

$ vendor/bin/rector process src --set php74
$ vendor/bin/phpstan analyse   # the 40 real errors surface here

Running the analyser immediately afterwards is the important step: converting the docblocks turns every incorrect one into a runtime TypeError waiting to happen, and the analyser finds most of them statically. Doing the conversion and the fixes in separate commits keeps the mechanical change reviewable.

The properties that resist conversion are the interesting ones. A property assigned an int in one method and a string in another has been a bug the whole time, and the migration is the moment it becomes visible — which is the best argument for doing it on a codebase that is otherwise working.

Verifying it worked

$ php -v
PHP 7.4.0 (cli)

$ vendor/bin/rector process src --set php74
[OK] 412 files changed

$ vendor/bin/phpstan analyse --no-progress
 [ERROR] Found 40 errors   # all properties whose docblocks were wrong

$ vendor/bin/phpunit
OK (1284 tests, 3891 assertions)

$ git diff --stat | tail -1
 412 files changed, 1,204 insertions(+), 3,881 deletions(-)

Deleting three lines for every one added is the shape of this change, and almost all of it is docblocks. The forty errors are the return on the migration: each is a property whose documented type was wrong and whose actual type nothing had been checking.

What this costs

An uninitialised typed property throws where null used to be returned, and that is a behavioural change in code paths nobody tests. A serialiser reading properties by reflection, a framework hydrating an object without a constructor, or a test double built with createMock can all produce an object with unset typed properties — and the failure is at the read, not the construction. Auditing anything that builds objects reflectively is worth doing before the deploy rather than after.

The other cost is a version floor. Typed properties are 7.4 syntax and a file containing them is a parse error on 7.3, which means the whole codebase moves at once and any library shipped from it drops support for everything older. For an application that is a scheduling question; for a package it is a decision about who can use it, and it is worth making deliberately rather than as a side effect of running a codemod.