On 24 January the internal admin started rendering shipment rows with dead action buttons. No 500, no error page, nothing in the log. The JSON response was missing a key, and the key was missing because a typed property had never been assigned and therefore, as far as PHP 7.4 is concerned, is not there at all. Six weeks into the upgrade that is the change I keep tripping over: a class with three typed properties and no constructor is a genuinely different kind of object than it used to be, and a good half of our data transfer objects were written on the assumption that it was not.
Context, because the rest of this depends on it. Symfony 4.4 LTS, MySQL 5.7, PHP-FPM on twelve EC2 instances behind a load balancer, Redis for sessions, a Vue admin talking to a JSON API. We put two canary boxes on 7.4.0, which shipped on 28 November, in the week after 7.4.1 landed on 18 December, and finished the fleet on 4 January. We are on 7.4.2 now. The mechanical part of the upgrade took an afternoon. Everything worth writing down happened afterwards.
What typed properties did to my constructors
Before 7.4 a property type was a comment. We had thousands of these:
final class RateRequest
{
/** @var string */
private $origin;
/** @var string */
private $destination;
/** @var float|null */
private $weightKg;
public function __construct(string $origin, string $destination, ?float $weightKg = null)
{
$this->origin = $origin;
$this->destination = $destination;
$this->weightKg = $weightKg;
}
}
Now the same class is nine lines shorter and the engine enforces it:
final class RateRequest
{
private string $origin;
private string $destination;
private ?float $weightKg;
public function __construct(string $origin, string $destination, ?float $weightKg = null)
{
$this->origin = $origin;
$this->destination = $destination;
$this->weightKg = $weightKg;
}
}
That is a nice trade, and it is not the whole story. Two rules from the typed properties RFC changed how I write classes rather than just how I annotate them. The first is that property types are invariant: a non-private property’s type cannot change in a subclass, not even from int to ?int, because a property is both read and written so neither covariance nor contravariance applies. There is a base entity here with a protected $reference that two subclasses redeclare, one of them nullable. Typing the base broke both. The fix was making it private with a protected accessor, which is what it should have been from the start.
The second rule is the one that cost us a production afternoon. A typed property with no default value does not get an implicit null. It is uninitialised, which is a third state alongside “holds a value” and “holds null”. The RFC is explicit that nullable properties do not get a free null either, on the grounds that a typo in a constructor should not silently produce null.
The uninitialised state is a new class of bug
Back to the dead buttons. This is the class, reduced:
final class ShipmentRow
{
public int $id;
public string $reference;
public ?string $podUrl = null;
}
Our list endpoint builds these objects field by field from a projection query rather than through a constructor, because the projection changes per query and a constructor with eleven nullable arguments is worse than the alternative. When the projection did not include the primary key, which happened on one code path that only the admin used, $id stayed uninitialised. Then this:
object(AppDtoShipmentRow)#412 (1) {
["id"]=>
uninitialized(int)
["reference"]=>
"SG-4471288"
["podUrl"]=>
NULL
}
Note the (1). The object reports one property. get_object_vars() skips uninitialised properties entirely, and since our serializer walks the object that way, id never reached the response body. A missing type would have given us null and a visible bug. A typed property gave us an absent key and a silent one, and it took two of us about ninety minutes to stop looking at the front end.
The loud version of the same problem showed up two days later, when a different path read the property instead of serializing it:
[2020-01-26 15:42:11] request.CRITICAL: Uncaught PHP Exception Error:
"Typed property AppDtoShipmentRow::$id must not be accessed before initialization"
at /var/www/app/src/Dto/ShipmentRow.php line 31
It is an Error, not a TypeError, which matters if you catch narrowly. And it is the correct behaviour. I would rather have this than a zero. But it is a new failure mode and it lives exactly where object graphs get built without constructors: serializers, denormalizers, ORM hydration, anything reaching for ReflectionClass::newInstanceWithoutConstructor(). The RFC even notes that unsetting a typed property returns it to the uninitialised state, and that this is kept deliberately because Doctrine uses it for lazy initialisation. So the state is load bearing, not an accident.
Our fix was boring and I would do it again. Every DTO that is built incrementally now declares the fields the caller may legitimately omit as nullable with an explicit default, and the fields that are structurally always present stay non-nullable and get assigned in one place:
final class ShipmentRow
{
public int $id;
public string $reference;
public ?string $podUrl = null;
private function __construct(int $id, string $reference)
{
$this->id = $id;
$this->reference = $reference;
}
public static function fromProjection(array $row): self
{
if (!isset($row['id'], $row['reference'])) {
throw new IncompleteProjection(implode(', ', array_keys($row)));
}
$dto = new self((int) $row['id'], (string) $row['reference']);
$dto->podUrl = $row['pod_url'] ?? null;
return $dto;
}
}
Arrow functions, spread, and the two-character win
The rest of 7.4’s surface is smaller in effect and far cheaper to adopt. Arrow functions capture by value implicitly, so the use ($x) boilerplate goes away. I have replaced maybe sixty closures with fn and the only thing that caught me is in the RFC’s own text: capture is by value, so fn() => $x++ does nothing to the outer $x. If you had a closure mutating a captured array through use (&$acc), an arrow function is not a drop-in replacement.
Null coalescing assignment is two characters of real relief in option normalisation, where we used to repeat a long expression on both sides of an assignment. Spread inside array expressions replaced most of our array_merge() calls in config building, and array_merge() now accepts zero arguments, which makes array_merge(...$arrays) safe on an empty list. Numeric literal separators are cosmetic and I use them anyway: 299_792_458 reads, 299792458 does not.
Preloading, honestly
This is the feature I wanted most and use least. Preloading takes an opcache.preload script, runs it once at server startup, and makes every class and function it touches permanently available, with parent and interface links already resolved. No per-request relinking. The manual’s own framing is a trade: convenience and performance against baseline memory.
Dmitry’s preload RFC reports roughly 30 percent on a Zend Framework 1 hello world (3620 req/sec against 2650) and roughly 50 percent on a ZF2 test app (1300 against 670), and then says plainly that real gains depend on the ratio of bootstrap overhead to actual runtime, and will most likely show up on short requests. Our tracking endpoint is not a short request. It is a couple of joins and a partner API call. Two canary boxes with a preload script over src/ and the framework, eleven days: p95 on that endpoint went from 118 ms to 112 ms. FPM worker RSS went up about 18 MB each. Six milliseconds is real, and it is not what the headline numbers led me to expect.
Two costs surprised me more than the gain disappointed me. The RFC notes that only classes with resolvable parents, interfaces, traits and constants can actually be preloaded, and that top-level declarations nested inside control structures cannot. We lost half a day to one vendor class that silently declined to preload for that reason. And the RFC’s backward compatibility section names the real trap: preloaded symbols are always present, so function_exists() and class_exists() return true where they used to return false. One of our polyfill guards flipped and started defining nothing.
The bigger objection is operational. The manual says it directly: clearing preloaded scripts requires restarting the PHP process, which is why the feature is only practical in production and not in development. So preload buys you a runtime that is structurally unlike the one your developers run, in exchange for milliseconds. We left it enabled on the internal admin, where bootstrap dominates a short request and a restart costs nothing, and switched it off on the public API. If our API were serving many small requests I would decide the other way.
The deprecations that actually fired
Our first canary box logged 3,411 deprecation notices in its first hour, across four 7.4 deprecations. Curly brace offsets were the volume leader:
Deprecated: Array and string offset access syntax with curly braces is deprecated
in /var/www/app/src/Partner/Reference/Checksum.php on line 44
That was 61 call sites in our code, all $ref{0} style, and a vendored SOAP client we no longer had a fork of. Mechanical fix, one PR, no thought required. Nested ternaries were the interesting one, because the notice tells you the code was probably already wrong:
Deprecated: Unparenthesized `a ? b : c ? d : e` is deprecated.
Use either `(a ? b : c) ? d : e` or `a ? b : (c ? d : e)`
Eleven of those. Two evaluated to something other than what the author obviously intended, both in display code, both wrong since 2018. Then array_key_exists() on objects, which we used in an old array-or-object helper and which the deprecations page tells you to replace with isset() or property_exists(), and implode($parts, ',') with the historical argument order in four places.
Six weeks in, the scoreboard: typed properties are worth the upgrade on their own and cost us one silent production bug plus one loud one, preloading is off where it would matter most, and the deprecation cleanup was a day of work that found two real bugs. What I have not done yet is go back through the entity layer. Doctrine hydrates those, and I want to understand what typing a mapped property does to a proxy before I touch 140 entity classes.
Sources
- PHP 7.4.0 release announcement for the feature list as the release team framed it.
- PHP 7 ChangeLog for the 7.4.0, 7.4.1 and 7.4.2 release dates.
- New features in PHP 7.4 for spread in array expressions, argument-less
array_merge(), numeric literal separators and the rest of the surface. - Typed Properties 2.0 RFC for type invariance, the absence of an implicit null default, the uninitialised state, and why
unset()on a typed property is kept for Doctrine. - Arrow Functions 2.0 RFC for by-value capture semantics and why mutation of captured variables does not work.
- Preloading in the manual for the memory trade, the restart requirement, and why it is a production-only feature.
- Preloading RFC for the ZF1 and ZF2 benchmark numbers, the limits on which classes can be preloaded, and the
function_exists()backward compatibility note. - Deprecated features in PHP 7.4 for curly brace offsets, nested ternaries,
array_key_exists()on objects and theimplode()argument order.