skip to content

After a PHP deploy renames or retypes properties of a class whose objects sit serialized in a cache, what does unserialize() do with the old payloads?

level: seniorimportance: should knowfreq 30%

answer

  1. payload keys matched to property names
  2. unknown key becomes a dynamic property
  3. missing typed property stays uninitialized
  4. type mismatch throws TypeError
  5. version the key or the payload

basics

~10 s

unserialize() assigns stored properties by name: an undeclared key becomes a dynamic property (deprecated since 8.2, Error on readonly classes), a new typed property stays uninitialized, and a wrongly typed value throws TypeError.

solid answer

~50 s

Without `__unserialize()`, PHP builds the object without its constructor, applies declared defaults, and assigns each payload entry to the property of the **same name**. A renamed property therefore splits in two: the old key has no declared property, so it is created as a **dynamic property**, which raises `E_DEPRECATED` since 8.2 and throws `Error` (`Cannot create dynamic property`) for a readonly class; the new property keeps its default or, if typed without one, stays **uninitialized** until a read throws `Error`. A stored value the new type does not accept, such as a string for an `int` property, makes `unserialize()` throw **`TypeError`**. A renamed class comes back as `__PHP_Incomplete_Class`. The fixes are to put a schema version in the cache key, or to implement `__unserialize()` with a version field, and to treat a failed decode as a cache miss.

code

php · 19 lines
php
<?php
declare(strict_types=1);

// Written by the previous release, where $rate was untyped and held "0.19".
$old = 'O:6:"Tariff":2:{s:4:"band";s:1:"A";s:4:"rate";s:4:"0.19";}';

final class Tariff
{
    public string $band;
    public float $rate;      // now typed
    public int $minorUnits;  // new, no default
}

try {
    $t = unserialize($old);
} catch (TypeError $e) {
    echo $e->getMessage(), "\n";
    // Cannot assign string to property Tariff::$rate of type float
}

go deeper

for a junior

Remember that serialized objects store property names and values, and that changing a class can break data written before the change.

for a middle

Explain the name-by-name mapping, why the constructor does not run, and what dynamic, uninitialized and mistyped properties each lead to.

for a senior

Prevent the incident: version cache keys or payloads, catch Throwable around decodes, and read the deprecation and TypeError signatures when it happens anyway.

for a principal

Decide which data may be stored as serialized objects at all, and make cache-schema versioning part of the release process rather than a per-team habit.

## How unserialize() maps a payload onto a class For an `O:` entry, and a class with no `__unserialize()` or `__wakeup()` logic of its own, PHP does the following: 1. Looks up the class by the stored name, invoking the autoloader if needed. 2. Creates an instance **without calling the constructor**, with declared default values applied. 3. For each stored name/value pair, assigns the value to the property **with that name**, checking declared property types. Nothing in that process knows about your refactor. The stored names and types are the ones the *old* release wrote, and they are matched literally against the *new* class. In a system that caches serialized objects between requests, every deploy that changes a cached class creates a window in which the new code reads old entries. ## What each kind of change does | Change in the new release | What unserialize() does with an old payload | |---|---| | property renamed from `$bands` to `$rates` | `$bands` is created as a dynamic property: `E_DEPRECATED` since 8.2, `Error` on a readonly class or a class that forbids them; `$rates` keeps its default or stays uninitialized | | new typed property with no default | stays **uninitialized**; the first read throws `Error` ("must not be accessed before initialization") | | property type narrowed, e.g. untyped to `int` | a stored value the type rejects throws **`TypeError`** from `unserialize()` | | property removed | the old value comes back as a dynamic property, as in the rename case | | class renamed or moved to another namespace | the old name no longer resolves; the object becomes `__PHP_Incomplete_Class` | Two details make these failures nasty in production: - **Where they surface.** An uninitialized property or an incomplete object does not fail at `unserialize()`. It fails later, in whichever request first touches the value, with a stack trace that points at business code rather than at the cache. - **Type checks are strict.** The typed-property check applied during unserialization does not coerce the way weak-mode assignment does: a numeric string stored under an old untyped property is rejected by a new `int` or `float` property. ## Four ways to make deploys safe - **Version the cache key.** Include a schema version or the release identifier in the key, for example `tariffs:v3:EU`. New code never reads old entries; old entries expire on their own. This is the simplest and most common fix. - **Version the payload.** Implement `__serialize()`/`__unserialize()` with a `'v'` field and translate older shapes in `__unserialize()`. This suits data that is expensive to rebuild or long-lived. - **Treat a failed decode as a miss.** Catch `\Throwable` around the decode, check the result with `instanceof`, and rebuild on anything unexpected. Catching only `Exception` misses `TypeError` and `Error`, which are `Error` subclasses. - **Flush on deploy.** Clearing the relevant cache namespace works for small caches, at the cost of a cold start after each release. Queues deserve special mention: a job serialized by the old release may be consumed by a worker already running the new one. There, versioned payloads or plain arrays are the only robust choice, because the key cannot be changed for jobs already in flight. ## A rollout checklist for cached classes 1. Before merging, list which classes end up in caches, sessions or queues as serialized objects. 2. For each one the change touches, decide: bump the cache-key version, or add a translation in `__unserialize()`. 3. Wrap every cache decode in a `try`/`catch (\Throwable)` that logs and rebuilds, so a missed case degrades to a cache miss instead of an error page. 4. After the deploy, watch the error log for the signatures below for as long as the longest cache TTL. ## Diagnosing it after the fact Look for `Creation of dynamic property` deprecations naming a cached class, `TypeError: Cannot assign ... to property ...` with `unserialize()` in the trace, `must not be accessed before initialization` on a property added in the last release, or warnings about incomplete objects. Each points at an entry written before the deploy.

  • Why catch Throwable rather than Exception around unserialize() of a cache entry?
    The failures a class change produces are `TypeError` and `Error` (dynamic property on a readonly class, uninitialized reads), and both extend `Error`, not `Exception`. A `catch (Exception $e)` lets them escape. Catching `\Throwable`, logging it and rebuilding treats the entry as a cache miss.
  • When is versioning the payload better than versioning the cache key?
    When the old data cannot simply be dropped: it is expensive to recompute, it lives in a queue already in flight, or it sits in long-lived storage. Then `__unserialize()` reads a `'v'` field and translates older shapes, so both releases' payloads stay readable during the rollout.

saying these in an interview costs you the question

  • unserialize() calls the constructor, so new properties get initialized
  • A renamed property is mapped automatically to its new name
  • Old payloads are coerced to the new property types like normal weak-mode assignment
  • catch (Exception $e) is enough to handle a failed unserialize() after a refactor
  • A renamed class makes unserialize() return false