PHP Serialization: serialize/unserialize, Magic Methods, and Object Injection

Use serialize() and unserialize() to convert PHP values to and from PHP's native serialized string format, implement __serialize/__unserialize to control an object's serialized representation, understand why unserialize() on untrusted input is a critical security vulnerability (PHP object injection), and apply safe alternatives like JSON and the allowed_classes option.

Before JSON was ubiquitous, PHP had its own native serialization format, produced by serialize() and consumed by unserialize(), used for things like storing complex data in a session, a cache entry, or a database column. It still shows up throughout the PHP ecosystem today. But unserialize() carries a security risk that json_decode() simply does not have: because PHP's serialized format can encode arbitrary object instances, feeding unserialize() untrusted data can let an attacker construct objects of classes they choose and trigger their magic methods -- a vulnerability class known as PHP object injection, responsible for real, high-severity CVEs in production PHP applications.

serialize() and unserialize() basics

serialize(mixed $value): string converts almost any PHP value -- scalars, arrays, and objects -- into a compact, PHP-specific text format that encodes both the value and its type information. A string is written as s:5:"hello"; (type s, byte length 5, the content, a terminating semicolon), an integer as i:42;, and an array as a:2:{i:0;s:1:"a";i:1;s:1:"b";} -- the count of entries followed by key/value pairs. Unlike JSON, this format preserves exact PHP type distinctions (integer vs float vs string) without any ambiguity, and can represent an object's full class identity, not just its data.

unserialize(string $data, array $options = []): mixed reverses the process, reconstructing the original PHP value, including recreating object instances of whatever class the serialized string names. That last part -- reconstructing arbitrary object instances by name -- is exactly the security-critical detail this lesson centers on: unserialize() decides which class to instantiate based entirely on data in the string it's given, not on anything the calling code specifies in advance.

Serializing objects: property visibility in the output

When an object is serialized, its class name and every property (public, protected, and private) are included in the output, with protected and private property names mangled with null-byte prefixes internally to record their visibility and declaring class. On unserialize(), PHP recreates an instance of that exact class WITHOUT calling its constructor -- properties are restored directly from the serialized data, bypassing whatever validation or setup the constructor would normally perform. This constructor-bypass behavior is itself a big part of why serialized objects need to be treated carefully: an object can end up in a state its own class was designed to prevent.

If a serialized string names a class that isn't loaded/autoloadable at unserialize() time, PHP produces an incomplete __PHP_Incomplete_Class object instead of failing outright, which can itself cause confusing downstream errors if the code doesn't account for that possibility -- another reason unserialize() output should never be treated as a guaranteed, fully-typed object without validation.

__serialize and __unserialize: the modern magic methods

Historically, classes controlled their own serialized representation with __sleep() (returning an array of property names to include, called before serialization) and __wakeup() (called after unserialize() reconstructs the object, for re-initializing resources like a database connection that can't itself be serialized). Since PHP 7.4, the preferred replacement is a pair of methods: __serialize(): array, which returns an arbitrary associative array representing the object's state (not limited to real property names), and __unserialize(array $data): void, which receives that array back and is responsible for populating the object -- and, importantly, __unserialize() IS effectively how the constructor's job gets done here, giving a class full control to validate the incoming data before accepting it.

__serialize/__unserialize are preferred over __sleep/__wakeup because they give full control over the exact shape of the serialized data (not just a property allow-list), they compose more predictably with typed and readonly properties, and critically, __unserialize() is a natural place to validate incoming data ranges/types before assigning them -- rejecting bad data with a thrown exception rather than silently accepting a malformed object state the way property-by-property restoration otherwise would.

• __sleep(): array (legacy) -> return property names to keep; runs before serialize() • __wakeup(): void (legacy) -> re-initialize after unserialize(); no control over what data is restored • __serialize(): array (modern) -> return any array representing state; full control of the output shape • __unserialize(array $data): void (modern) -> receives that array, must populate $this; the right place to validate

PHP object injection: why unserialize() on untrusted input is dangerous

PHP object injection is a vulnerability class where an attacker supplies serialized data to an application's unserialize() call, and because unserialize() instantiates whatever class the string names (any class already loaded or autoloadable in the application, not just ones the developer intended), the attacker gets to choose which class gets constructed and what property values it ends up with -- entirely bypassing that class's constructor. This becomes dangerous when the application has ANY class, anywhere in its loaded code (including third-party libraries), whose magic methods -- __destruct(), __wakeup(), __toString(), __unserialize() -- perform a sensitive side effect based on object property values: deleting a file, running a database query, including a file path, or similar. Such a class is called a 'gadget', and a chain of gadget classes strung together is a 'gadget chain' -- the same general technique used in insecure-deserialization vulnerabilities across many languages, not unique to PHP.

Conceptually (this is NOT a working exploit, just illustrating the shape of the risk): imagine a Logger class somewhere in the application or a dependency has a __destruct() method that does something like write file contents to a path taken from a property, intended only for legitimate internal logging cleanup. An attacker who can get their own serialized string passed to unserialize() -- for example if a cookie value, a cache key, or an uploaded file's contents is deserialized without validation -- could craft a string naming that exact class with a crafted property value, and when PHP garbage-collects the resulting object at the end of the request, __destruct() fires using the attacker-controlled property, potentially writing to an unintended file path. The application code that calls unserialize() may never even reference the Logger class directly -- the vulnerability lives entirely in the fact that unserialize() can instantiate ANY loaded class the attacker names, using data the attacker supplies for its properties.

This is precisely why the standing rule in PHP security guidance is unconditional: never call unserialize() on data that originates from a user, a request, a cookie, an uploaded file, or any other untrusted source, no matter how the class involved looks 'harmless' -- the danger isn't in the calling code's own classes, it's in every gadget class reachable anywhere in the application's dependency tree, which the calling developer usually can't fully audit.

Safe alternatives: JSON and allowed_classes

The simplest, most robust fix is to not use PHP's native serialization format for untrusted data at all: use json_encode()/json_decode() instead. JSON has no concept of instantiating arbitrary classes -- decoding JSON can only ever produce plain arrays, stdClass objects with public properties, and scalars, so there is no mechanism by which a JSON payload could trigger a magic method or construct an object of the attacker's choosing. For any data crossing a trust boundary (session values touched by shared hosting, cache entries, API tokens, cookie contents), JSON is the safe default and should be preferred over serialize()/unserialize() unless there's a specific reason PHP's richer type-preserving format is required internally.

When native PHP serialization genuinely must be used with data that isn't fully trusted, unserialize()'s second parameter accepts an options array with an 'allowed_classes' key that restricts which classes it's willing to instantiate: passing ['allowed_classes' => false] disables object instantiation entirely (any serialized object becomes __PHP_Incomplete_Class instead of a real instance of anything), and passing ['allowed_classes' => ['SafeClassA', 'SafeClassB']] allows only that explicit list, converting anything else to __PHP_Incomplete_Class. This meaningfully shrinks the attack surface by preventing arbitrary gadget classes from being instantiated, but it is a mitigation, not a substitute for the real fix of not deserializing untrusted native PHP data in the first place -- omitting the option entirely (the pre-PHP-7-default behavior of allowing all classes) is what makes the classic exploit possible.

serialize / unserialize signatures and safe usage

The 'allowed_classes' option is a defense-in-depth measure for semi-trusted data, not a green light to unserialize() fully untrusted input. __unserialize() runs instead of the constructor, so it is the correct place to re-validate data exactly as a constructor would.

// Signatures
serialize(mixed $value): string
unserialize(string $data, array $options = []): mixed

// NEVER do this with untrusted/user-supplied $data:
$obj = unserialize($data);                       // allows ANY loaded class to be instantiated

// If native PHP serialization is unavoidable for semi-trusted data, restrict classes:
$obj = unserialize($data, ['allowed_classes' => false]);            // no objects at all, safest
$obj = unserialize($data, ['allowed_classes' => [MyDto::class]]);   // explicit allow-list only

// Preferred for anything crossing a trust boundary: use JSON instead
$data = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); // no class instantiation possible

// Modern object (de)serialization control
class Money {
    public function __construct(private int $cents, private string $currency) {}

    public function __serialize(): array {
        return ['cents' => $this->cents, 'currency' => $this->currency];
    }

    public function __unserialize(array $data): void {
        if ($data['cents'] < 0) {
            throw new UnexpectedValueException('cents cannot be negative');
        }
        $this->cents = $data['cents'];
        $thi
…

Code Examples

For plain arrays and scalars (no objects involved), serialize()/unserialize() simply round-trips the data, preserving exact types (integers stay integers, unlike some other formats). Passing allowed_classes: false here is good habit even though this particular data has no objects in it -- it costs nothing and blocks object instantiation if the data ever unexpectedly contains one. Expected result: a:2:{s:4:"name";s:3:"Ada";s:6:"scores";a:3:{i:0;i:10;i:1;i:20;i:2;i:30;}} bool(true)

__serialize() controls exactly what goes into the serialized string (here, deliberately excluding no data but demonstrating the array is fully custom, not tied to raw property names). __unserialize() then validates the incoming array's shape before assigning it to the object -- doing the job the bypassed constructor normally would. Restricting allowed_classes to exactly ShoppingCart::class means even if this serialized string were tampered with to name a different class, unserialize() would refuse to instantiate it.

'allowed_classes' => [ShoppingCart::class] This example deliberately stops short of a working payload -- it shows the mechanism (a 'gadget' class whose magic method does something sensitive with its own property data, combined with unserialize() letting an attacker choose the class and property values) without providing exploit code. The takeaway is structural: the vulnerability lives in unserialize()'s ability to instantiate any loaded class by name, not in any specific line of the calling code, which is why the fix is to avoid unserialize() on untrusted data entirely rather than trying to make each call site individually 'safe'.

Common mistake: Do not copy the commented-out CacheFileHandle/unserialize($_COOKIE...) shape into real code, even for testing -- it exists only to illustrate the risk pattern.

<?php

$data = [
    'name' => 'Ada',
    'scores' => [10, 20, 30],
];

$serialized = serialize($data);
echo $serialized;
// a:2:{s:4:"name";s:3:"Ada";s:6:"scores";a:3:{i:0;i:10;i:1;i:20;i:2;i:30;}}

$restored = unserialize($serialized, ['allowed_classes' => false]);
var_dump($restored === $data); // bool(true)
<?php

final class ShoppingCart
{
    private array $items = [];

    public function __construct(private readonly string $currency)
    {
    }

    public function addItem(string $sku, int $quantity): void
    {
        $this->items[$sku] = $quantity;
    }

    // Modern replacement for __sleep()
    public function __serialize(): array
    {
        return [
            'currency' => $this->currency,
            'items' => $this->items,
        ];
    }

    // Modern replacement for __wakeup() -- validates on the way back in
    public function __unserialize(array $data): void
    {
        if (!isset($data['currency'], $data['items']) || !is_array($data['items'])) {
            throw new UnexpectedValueException('Malformed ShoppingCart data');
        }
        $this->currency = $data['currency'];
        $this->items = $data['items'];
    }
}

$cart = new ShoppingCart('USD');
$cart->addItem('SKU-1', 2);

$serialized = serialize($cart);
echo $serialized . "\n";

// unserialize() never calls the constructor -- __unserialize() does the validating instead
$restored = unserialize($serialized, ['allowed_classes' => [ShoppingCart::class]]);
var_dump($restored);
<?php
// CONCEPTUAL ONLY -- illustrates the SHAPE of PHP object injection risk.
// This is not a working exploit against any real class; it exists purely
// to show WHY unserialize() on untrusted input is dangerous.

// Suppose some class, anywhere in the app or a loaded dependency,
// has a magic method that performs a sensitive action using its own
// property values -- e.g. a cache/log helper cleaning up a file on destruct:
//
//   class CacheFileHandle {
//       public string $path;
//       public function __destruct() {
//           // intended for internal cleanup of files THIS class created --
//           // not designed to ever receive an attacker-chosen $path
//           @unlink($this->path);
//       }
//   }
//
// If ANY code path in the application does this with request-controlled data:
//
//   $cached = unserialize($_COOKIE['session_cache']); // DANGEROUS -- never do this
//
// ...an attacker does not need to know anything about how that unserialize()
// call is used. They only need SOME gadget class with a dangerous magic method
// to exist anywhere in the loaded codebase. They craft a serialized string
// naming that class with a property value of their choosing
…

Object injection: who controls what

The application never chose which class to instantiate -- the serialized string did. That single fact is the entire vulnerability.

Trusted flow (safe):
    App code  --serialize()-->  string  --unserialize()-->  App-chosen class, app-supplied data

  Untrusted flow (PHP object injection risk):
    Attacker  --crafts string naming ANY loaded class-->  unserialize(untrusted)
                                                                   |
                                                                   v
                                                   PHP instantiates that class,
                                                   sets attacker-chosen properties,
                                                   constructor is bypassed entirely
                                                                   |
                                                                   v
                                        magic method (__destruct/__wakeup/__toString)
                                        fires later using attacker-controlled state

Important Rules

Never call unserialize() on data from a user, request, cookie, uploaded file, or any other untrusted source -- treat this as an absolute rule, not a case-by-case judgment call.

Prefer JSON (json_encode/json_decode) over native PHP serialization for anything crossing a trust boundary, since JSON decoding cannot instantiate arbitrary classes.

If unserialize() on semi-trusted data is unavoidable, always pass an explicit 'allowed_classes' option (an allow-list, or false) -- never omit it and allow every loaded class.

Implement __serialize()/__unserialize() rather than __sleep()/__wakeup() in new code, and use __unserialize() as the place to validate incoming data since the constructor is bypassed.

Remember unserialize() never calls the constructor -- any invariant the constructor normally enforces must be re-checked in __unserialize(), or the object can end up in an invalid state.

Where this shows up

PHP object injection has caused real, high-severity CVEs in widely-used software -- vulnerable code paths have included deserializing cookie values, cache keys, and uploaded file metadata without an allowed_classes restriction, some of them in popular CMS platforms and frameworks in years past. This is exactly why modern PHP security guidance (OWASP's PHP guidance included) treats 'never unserialize() untrusted input' as a hard rule rather than a suggestion, and why session handlers, caching libraries, and API layers in current frameworks increasingly default to JSON or igbinary-style formats for anything that might touch external input, reserving native serialize()/unserialize() for fully internal, trusted data paths.

<?php // Storing user-controlled cart data across requests in a cookie setcookie('cart', serialize($cart)); // ...next request... $cart = unserialize($_COOKIE['cart']); // attacker fully controls this string

<?php setcookie('cart', json_encode($cart->jsonSerialize(), JSON_THROW_ON_ERROR)); // ...next request... try { $data = json_decode($_COOKIE['cart'] ?? '[]', associative: true, flags: JSON_THROW_ON_ERROR); } catch (JsonException) { $data = []; } $cart = ShoppingCart::fromArray($data); // app code controls which class gets built, and validates $data

Cookies are entirely attacker-controlled -- unserialize() on cookie content is a textbook PHP object injection vector, letting an attacker name any loaded class. Switching to JSON removes the class-instantiation risk completely: json_decode() can only ever produce arrays/scalars/stdClass, and the application code itself decides which real class to construct from that data, with its own validation.

[multiple-choice] Why is calling unserialize() on user-supplied data considered a critical security risk in PHP? A. Because serialize()'s text format is easy for humans to read and edit B. Because unserialize() can instantiate an instance of ANY loaded/autoloadable class named in the string, bypassing its constructor, which can trigger dangerous magic methods on attacker-controlled data (PHP object injection) C. Because unserialize() is deprecated and produces a warning in modern PHP D. Because it only works correctly for arrays, not for scalar values

B. Because unserialize() can instantiate an instance of ANY loaded/autoloadable class named in the string, bypassing its constructor, which can trigger dangerous magic methods on attacker-controlled data (PHP object injection) — The core risk is that the serialized string itself names which class to instantiate, not the calling code -- so an attacker can choose any 'gadget' class in the application's loaded code whose magic methods (__destruct, __wakeup, __toString, etc.) perform a sensitive action based on property values, and supply those property values directly, all while bypassing the class's own constructor.

[multiple-choice] What is the safest general alternative to calling unserialize() on data from an untrusted source like a cookie or request body? A. Call unserialize() twice to double-check the result B. Use json_encode()/json_decode() instead, since JSON decoding cannot instantiate arbitrary PHP classes C. Base64-encode the serialized string before storing it D. Disable error reporting so any warnings from unserialize() are hidden

B. Use json_encode()/json_decode() instead, since JSON decoding cannot instantiate arbitrary PHP classes — JSON has no mechanism for encoding 'instantiate this class' -- decoding JSON can only ever produce arrays, scalars, and (optionally) stdClass objects with public properties, so there is no path for a JSON payload to trigger arbitrary class instantiation or magic methods the way a crafted PHP-serialized string can.

[multiple-choice] What is the purpose of __unserialize(array $data): void, and why does it matter that it -- not the constructor -- is called during unserialize()? A. It has no real purpose; it is purely cosmetic legacy syntax B. It receives the data that __serialize() produced and must populate the object; because the constructor is bypassed during unserialize(), __unserialize() is the correct place to validate incoming data before accepting it C. It runs only when unserialize() fails, as an error handler D. It automatically calls the class's constructor internally, so no extra validation is ever needed

B. It receives the data that __serialize() produced and must populate the object; because the constructor is bypassed during unserialize(), __unserialize() is the correct place to validate incoming data before accepting it — unserialize() reconstructs an object's state directly, without invoking its constructor, so any validation or normalization the constructor would normally perform must happen somewhere else -- and __unserialize() is that place, giving the class a chance to reject malformed or out-of-range data before it becomes part of a live object.

Write a Money class with private int $cents and private string $currency, implementing __serialize() (returning both fields as an array) and __unserialize() (which must throw an UnexpectedValueException if cents is negative or currency is not a 3-letter uppercase string, otherwise assigns both fields). Then write a function loadMoneyFromUntrustedSource(string $raw): Money that decodes JSON (never native unserialize) and constructs a Money instance from it, wrapping json_decode in try/catch for JsonException.

final class Money { public function __construct(private int $cents = 0, private string $currency = 'USD') {} public function __serialize(): array { return ['cents' => $this->cents, 'currency' => $this->currency]; } public function __unserialize(array $data): void { if ($data['cents'] < 0) { throw new UnexpectedValueException('cents cannot be negative'); } if (!preg_match('/^[A-Z]{3}$/', $data['currency'])) { throw new UnexpectedValueException('currency must be a 3-letter uppercase code'); } $this->cents = $data['cents']; $this->currency = $data['currency']; } } function loadMoneyFromUntrustedSource(string $raw): Money { try { $data = json_decode($raw, associative: true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new InvalidArgumentException('Invalid money payload', previous: $e); } $money = new Money(); $money->__unserialize($data); // reuse the same validation path return $money; }

__unserialize() receives the array from __serialize() and is the right place to re-validate; the loader function should use json_decode with JSON_THROW_ON_ERROR and never call PHP's unserialize() on the raw input.