In PHP, what do unserialize()'s allowed_classes and max_depth options change, and what does each leave unprotected?
answer
- array of names, true or false
- __PHP_Incomplete_Class, not rejection
- listed classes still run their hooks
- unserialize_max_depth, default 4096
- 8.4: bad option type throws
basics
~10 sallowed_classes limits which classes unserialize() instantiates; any other class becomes __PHP_Incomplete_Class. max_depth caps nesting, default 4096 from unserialize_max_depth. Neither makes untrusted input safe: listed classes still run their hooks, and size is not bounded.
solid answer
~40 s`allowed_classes` takes an **array of class names**, `false` (no classes) or `true` (all classes, the default when omitted). An object of any other class is not rejected: it comes back as a **`__PHP_Incomplete_Class`** placeholder, which warns when a property is read and throws `Error` when a method is called. Enum cases are not affected by the option. Classes you do list are fully instantiated, so their `__unserialize()`/`__wakeup()` and later `__destruct()` run on whatever the payload says. `max_depth` limits **nesting depth**; the default comes from the `unserialize_max_depth` ini setting (4096), `0` disables it, and exceeding it emits an `E_WARNING` and returns `false`. It bounds recursion, not total size. Since PHP 8.4 an `allowed_classes` value that is neither an array of names nor a bool throws `TypeError` or `ValueError`.
code
php · 17 lines<?php
declare(strict_types=1);
$cacheFile = __DIR__ . '/var/cache/tariffs-eu.ser';
$raw = is_file($cacheFile) ? file_get_contents($cacheFile) : false;
$table = $raw === false ? false : unserialize($raw, [
'allowed_classes' => [TariffTable::class],
'max_depth' => 16,
]);
if (!$table instanceof TariffTable) {
// false for a malformed or too-deep payload,
// __PHP_Incomplete_Class for any class not on the list
$table = buildTariffTable('EU');
file_put_contents($cacheFile, serialize($table), LOCK_EX);
}go deeper
Know the two option names, and that allowed_classes restricts which classes unserialize() is willing to instantiate from a payload.
Explain the incomplete-class placeholder, the 4096 default from unserialize_max_depth, and the warning-plus-false behaviour when depth is exceeded.
Treat both options as defence in depth for trusted payloads, check result types, and keep user-controlled bytes out of unserialize() entirely.
Set the organisational rule: which stores may hold PHP-serialized data, who can write them, and when a signed or JSON format is mandatory.
## The options array `unserialize(string $data, array $options = []): mixed` takes two options. Both matter when you read back a payload, for example a computed tariff table cached between requests, and both are easy to over-trust. | Option | Accepts | Default | Effect | |---|---|---|---| | `allowed_classes` | array of class names, `true`, `false` | `true` (all classes) | objects of other classes become `__PHP_Incomplete_Class` | | `max_depth` | int | `unserialize_max_depth` ini, 4096 | nesting deeper than this fails the call; `0` disables the limit | ## allowed_classes in detail - **Array of names**: only those classes are instantiated. Pass `Class::class` constants so a rename is caught by tooling. - **`false`**: no class is instantiated; every `O:` entry becomes a placeholder. Arrays and scalars decode normally. - **`true`** or omitted: any class may be instantiated, which may also trigger the autoloader. - A class that is allowed but cannot be found, even after autoloading, also becomes `__PHP_Incomplete_Class`. The `unserialize_callback_func` ini directive, empty by default, can name a function PHP calls first to define the missing class. - **Enum cases** are unaffected by the option, per the manual. - Since **PHP 8.4**, a value that is neither an array of class names nor a bool throws `TypeError` or `ValueError`. Before 8.4 a wrong type made the call return `false` with a warning. A disallowed object is **not an error**. It becomes an instance of `__PHP_Incomplete_Class` that remembers the original class name. Reading a property on it emits an `E_WARNING` and gives `null`; writing a property or calling a method throws `Error` ("The script tried to call a method on an incomplete object"). Code that forgets to check the result therefore fails later, far from the decode. ## What allowed_classes does not do The option decides *which* classes may be built; it does not decide *what state* they receive. For every class you allow: 1. PHP creates an instance without the constructor. 2. It fills properties from the payload, or calls `__unserialize()` with the payload's array. 3. The object's `__destruct()` runs when it is released. If an attacker can write the bytes, each allowed class's hooks run on attacker-chosen data. The PHP manual therefore warns against passing untrusted input to `unserialize()` *regardless* of `allowed_classes`, and recommends JSON for data from users, or an HMAC over data you store externally so you can prove you wrote it. The exploitation side of that story belongs to the security topics; the working rule here is that `allowed_classes` is **defence in depth for data you already trust**, not a filter for data you do not. ## max_depth in detail - Each nested array or object level counts. Exceeding the limit emits `E_WARNING` ("Maximum depth of N exceeded. The depth limit can be changed using the max_depth unserialize() option or the unserialize_max_depth ini setting") and the call returns `false`. - The purpose is to avoid **stack overflow** from deeply nested payloads. - It does **not** bound the payload's size: a shallow array with millions of elements, or a very long string, passes the depth check and is limited only by `memory_limit`. - A nested `unserialize()` call made from inside a class hook that passes its own `max_depth` starts counting from zero again. ## A sane read path for a cache you own - Pass `allowed_classes` listing exactly the classes the cache holds. - Set a `max_depth` that fits your data, well below 4096. - Check the result's type with `instanceof` and rebuild the value on anything else: `false`, an incomplete object, or the wrong class. - Keep payloads from untrusted sources out of `unserialize()` altogether. ## How interviewers probe it Expect three follow-ups. First, *what exactly comes back* for a disallowed class: an object, not `false`, which is why an `instanceof` check matters. Second, *whether `allowed_classes => false` makes user input safe*: it removes object instantiation, but the manual still advises against unserializing untrusted input, and JSON is the documented alternative. Third, *what `max_depth` protects*: recursion depth, not memory, so a size limit on the raw string belongs in front of the call.
- What exactly do you get back when a payload names a class that allowed_classes excludes?An object of `__PHP_Incomplete_Class` that keeps the original class name and the stored properties. Reading a property emits a warning and yields `null`; writing a property or calling a method throws `Error`. The `unserialize()` call itself succeeds, so the caller must check the type.
- Does max_depth protect an endpoint from a huge payload?No. It limits nesting only, so it prevents stack exhaustion from deep structures. A shallow payload with a very large element count or a very long string passes it and is bounded only by `memory_limit` and whatever size limit you enforce before calling `unserialize()`.
allowed_classes is a door list that, instead of turning strangers away, lets them in wearing a blank name badge: nothing they try to do works, but they are inside, and guests on the list are fully trusted whatever they carry.
saying these in an interview costs you the question
- allowed_classes => false makes unserialize() safe for user input
- A disallowed class makes unserialize() return false
- max_depth also caps the total size of the payload
- Classes on the allowed list are built without running any of their code
- allowed_classes also blocks enum cases not on the list