The dynamic property that had been a typo since 2016

PHP 8.2 arrived on the eighth of December. Its most consequential change is a deprecation rather than a feature: assigning to an undeclared property now emits a notice, which means that for the first time since PHP 4 the engine objects to a typo in a property name.

The symptom

final class OrderTotals
{
    public int $total = 0;
    public int $vat = 0;
}

$t = new OrderTotals();
$t->totl = 4900;      // creates a property called 'totl'

$t->total;            // 0. and the invoice said £0.00.

// 8.1: no notice, no warning, no error.
// 8.2: Deprecated: Creation of dynamic property
//      OrderTotals::$totl is deprecated
# and what the deprecation found, on an 8.2 test run:
$ vendor/bin/phpunit 2>&1 | grep -c 'dynamic property'
1204

$ vendor/bin/phpunit 2>&1 | grep -oP 'property K[^ ]+' 
    | sort | uniq -c | sort -rn | head -5
    412 AppModelsOrder::$_pivot_cache
    188 AppHttpResourcesOrderResource::$additional
     88 AppSupportBag::$anything
     11 AppReportingRow::$totl        ← a typo
      4 AppServicesSync::$lastRunAt   ← a typo

Twelve hundred occurrences reducing to five distinct causes, of which two are genuine typos, is the shape of this deprecation everywhere. The $totl one had been in a reporting class since 2016 and had been producing a column of zeroes that somebody had learned to ignore.

Why it happens

PHP has allowed assigning to any property since the language had objects, because objects were associative arrays with methods. Every typo in a property name has therefore been a working assignment to a property that nothing reads, and the only signal has been the value being wrong somewhere else.

Typed properties in 7.4 made a read of an uninitialised property an error, which caught half of it — the read side. The write side had no check at all until now.

The fix

What is and is not affected

deprecated:
  a property assigned on a class that does not declare it

NOT affected:
  stdClass, and anything extending it
  a class implementing __set (the magic method runs first)
  a class implementing __get for reads
  a declared property, obviously
  ArrayObject and friends
  a property created by unserialize() or by reflection

and the timeline:
  8.2  Deprecated notice
  9.0  Error

so the runway is years, and the notice is the point.

The __set exemption is what makes this survivable for the classes that use dynamic properties deliberately — a property bag with a magic setter is unaffected, because the magic method intercepts before the engine would create anything. That covers most of the legitimate uses.

Finding them all

// the static approach: an analyser rule
// phpstan.neon
parameters:
  level: 8
  checkDynamicProperties: true

// which finds what it can see, and cannot see:
//   $obj->{$name} = $value
//   a property set from an array in a loop
//   anything on a mixed
// the runtime approach, which finds the rest: promote the
// deprecation to an exception in the test environment
set_error_handler(function (int $no, string $msg, string $file, int $line) {
    if ($no === E_DEPRECATED && str_contains($msg, 'dynamic property')) {
        throw new ErrorException($msg, 0, $no, $file, $line);
    }

    return false;
}, E_DEPRECATED);

// and in production, log rather than throw — the notice
// carries the class and the property name, which is
// everything needed to fix it.

The two approaches find different sets and both are needed: the analyser catches code that no test exercises, and the runtime handler catches the dynamic assignments the analyser cannot see. Logging in production for a month found four occurrences that neither had.

The ORM problem

// 412 of the 1,204 were one thing: an ORM model caching a
// pivot on the instance
$order->_pivot_cache = $pivot;

// which is a dynamic property on a class that implements
// __set — so why the notice?
//
// because the model's __set writes to an internal
// attributes array for KNOWN columns and falls through
// to a real property assignment for anything else, and
// the fall-through is the deprecated path.

// the framework fixed this in a patch release. the
// application code that did the same thing did not.

A model that implements __set and conditionally does not intercept is the case that surprises everybody, because the exemption looks like it should apply. The framework released a fix within weeks; the four places in the application doing the same thing were ours to find.

This is also the reason the deprecation is more disruptive for framework-heavy code than the rule suggests: the exemptions are about the mechanism rather than about intent, and a magic setter that delegates in some cases is not exempt in the others.

The attribute, and when it is honest

#[AllowDynamicProperties]
class LegacyBag
{
    // a class whose entire purpose is arbitrary attributes,
    // written in 2014 before stdClass was idiomatic here.
    // documented, and scheduled for replacement in Q2.
}

// and the property that makes it dangerous:
// the attribute is INHERITED. applying it to a base model
// exempts every model in the application, which is exactly
// what somebody under deadline pressure will do.

Inheritance of the attribute is what makes it a bad quick fix and an acceptable deliberate one. Two classes here carry it, both with a comment explaining why and a ticket number, and a lint rule counts them so that a third requires a conversation.

$ grep -rn 'AllowDynamicProperties' src/ | wc -l
2

# the CI check
$ [ "$(grep -rc 'AllowDynamicProperties' src/ | 
      awk -F: '{s+=$2} END {print s}')" -le 2 ] 
    || { echo 'a new AllowDynamicProperties needs review'; exit 1; }

The three honest alternatives

// 1. declare the properties. the answer 90% of the time.
final class Row
{
    public function __construct(
        public readonly int $total,
        public readonly int $vat,
    ) {}
}

// 2. extend stdClass, for a genuine property bag
final class Payload extends stdClass {}

// 3. hold an array, and expose it through __get/__set
final class Bag
{
    private array $data = [];

    public function __get(string $k): mixed { return $this->data[$k] ?? null; }
    public function __set(string $k, mixed $v): void { $this->data[$k] = $v; }
}

// (3) is the honest one for a class that genuinely accepts
// arbitrary keys, and the one an analyser understands least.

The third is the one that reads worst and is the most honest for a class that genuinely accepts arbitrary keys, because the storage is explicit and the class can validate. It is also the version that a static analyser understands least, which is the trade.

The two typos

AppReportingRow::$totl
  set in 2016, read never. the report column had shown
  £0.00 for six years and had been described in a
  handover document as "not currently used".

AppServicesSync::$lastRunAt
  set on each run, read by a health check that compared
  it against $last_run_at. the check had been reporting
  "never run" since 2021 and had been silenced.

both are the same bug: a write that lands nowhere, and a
read that finds nothing, separated by enough distance
that nobody connected them.

A silenced health check reporting “never run” for eighteen months is the more interesting of the two, because the silence was the response to the symptom. The deprecation notice named the property and the class in one line, which is the entire diagnosis that had eluded two people.

Verifying it worked

$ php -v
PHP 8.2.0 (cli)

$ vendor/bin/phpunit 2>&1 | grep -c 'dynamic property'
0

$ vendor/bin/phpstan analyse
 [OK] No errors

# a month of production, with the notice logged
$ grep -c 'dynamic property' /var/log/app/*.log
0

# and the report column that had been £0.00 since 2016
$ curl -s /admin/reports/monthly | jq -r '.rows[0].total'
412008

The report column producing a number is the outcome that mattered to somebody outside engineering, and it needed explaining: the figure had been wrong since 2016 and is now right, which means the historical exports do not match. That conversation is the actual cost of finding a six-year-old bug.

What this costs

An escape-hatch attribute that will outlive its justification, and a lint rule counting its uses so that a third one requires a conversation. Two classes carrying it with a ticket number is a manageable position and the pressure to add a fourth arrives every time somebody meets the deprecation under a deadline.

The twelve hundred notices are also mostly noise, which is the reason people disable the deprecation rather than working through it. Reducing them to five distinct causes took an afternoon of grouping by class and property, and doing that first is what turned an unmanageable number into a list of five decisions.

The honest summary is that this deprecation found two real bugs, one of which had been producing wrong figures for six years, and cost about three days across the codebase. That ratio will not hold for every project — a codebase with a property bag pattern everywhere faces a genuine redesign — and the runway to 9.0 is years, which is enough time to do it properly rather than with an attribute.