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?
answer
- payload keys matched to property names
- unknown key becomes a dynamic property
- missing typed property stays uninitialized
- type mismatch throws TypeError
- version the key or the payload
basics
~10 sunserialize() 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 sWithout `__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
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
Remember that serialized objects store property names and values, and that changing a class can break data written before the change.
Explain the name-by-name mapping, why the constructor does not run, and what dynamic, uninitialized and mistyped properties each lead to.
Prevent the incident: version cache keys or payloads, catch Throwable around decodes, and read the deprecation and TypeError signatures when it happens anyway.
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