PHP Serialization: JSON vs. serialize() Security Comparison
AI generated
<?php
8.4
PHP · Serialization · JSON · Security
PHP Serialization Without the Risk
JSON, serialize() and the line between them

PHP serialization decides more than just the data format: serialize() is fast and preserves every object property, but quickly becomes an entry point for object injection with untrusted data. JSON is safe and language independent, at the cost of object fidelity and some speed. This article shows how both formats work internally, where unserialize() turns dangerous, and how a clean migration in existing systems succeeds.

15 min read serialize() · json_encode() · Object Injection PHP 8.4

1. What PHP serialization means and what it is used for

PHP serialization refers to converting a value in memory, a scalar, an array, or an object, into a linear string that can be stored, transmitted, or cached and later converted back into the very same value. The term is deliberately an umbrella for two dominant approaches in the PHP ecosystem: the native serialize()/unserialize() functions built firmly into the language, and JSON serialization via json_encode()/json_decode(). Both solve the same underlying problem, structured data has to cross a process boundary without losing its shape.

The practical use cases are diverse. PHP session handling stores $_SESSION as a serialized string by default in whichever session handler is configured, filesystem, Redis, or database. Caching layers such as APCu, Redis, or Memcached store computed results, query results, rendered fragments, configuration arrays, as serialized strings because the cache backend itself only understands byte strings, not native PHP structures. Message queues like RabbitMQ or SQS also need a wire format: a producer serializes a job payload, a consumer deserializes it on the other side, often in a different codebase or even a different language.

APIs today rely almost exclusively on JSON, because HTTP clients across languages need to parse response bodies, and JSON has become the common language of data interchange. This very diversity of use cases is exactly why choosing the right PHP serialization strategy matters: a wrong choice in an API context, native serialize() exposed in a public interface, creates security and interoperability problems, while a wrong choice in a purely internal cache, JSON for deeply nested object graphs, costs performance for no real benefit.

2. Native serialization with serialize() and unserialize()

serialize() walks the given value recursively and produces a compact, self-describing string in a proprietary format that only PHP understands natively. Every scalar type gets a single-letter prefix: b for boolean, i for integer, d for double or float, s for string, N for null, followed by the length or value and a terminating semicolon. The string "hello" is encoded as s:5:"hello";, for example, s is the type, 5 the byte length, and the quoted content the actual value. This length prefix is the standout characteristic of the format and the reason unserialize() is so fast, it never has to scan for a terminator character, it simply reads exactly the announced number of bytes.

Arrays are represented as a:N:{...}, where N is the item count and the braces contain N key-value pairs, alternating key then value, each individually type-prefixed. Objects use the format O:len:"ClassName":N:{...}, meaning the class name is embedded directly into the payload, followed by the N properties, with private and protected property names mangled internally, private properties get the class name and null bytes prepended, protected ones a single null-byte marker, so unserialize() can restore visibility correctly later.

This exact self-describing nature, class name and property structure encoded right into the string, is powerful within the same codebase, but it is also precisely why PHP serialization with the native format turns dangerous the moment the string can come from outside: unserialize() will happily attempt to instantiate any class named in the payload, provided it is autoloadable, before the application code ever gets a chance to validate anything.

3. JSON serialization with json_encode() and json_decode()

json_encode() converts a PHP value into a JSON string following the language-agnostic JSON specification (RFC 8259): objects become key-value maps in curly braces, arrays become ordered lists in square brackets, strings are UTF-8 with defined escaping rules, and there is no native distinction between integer and float beyond the number's own representation. Because JSON carries no class names and no explicit type prefix for scalars, json_decode() returns stdClass objects for JSON objects by default, unless the second parameter $associative is set to true, in which case it returns nested associative arrays instead, the far more common choice in modern PHP serialization workflows that use JSON as the interchange format.

A handful of flags materially change the behavior of json_encode()/json_decode() in PHP 8.4 and should be treated as defaults rather than optional extras. JSON_THROW_ON_ERROR turns encoding or decoding failures into a JsonException instead of relying on the classic pattern of a silent false return value plus a json_last_error() check, which is easy to miss in review. JSON_UNESCAPED_UNICODE keeps non-ASCII characters like umlauts or emoji as literal UTF-8 bytes instead of \uXXXX escape sequences, which matters for payload size and log readability. JSON_UNESCAPED_SLASHES avoids escaping every forward slash in URLs. JSON_PRETTY_PRINT is useful for debugging output and API documentation examples but should never be used for production payloads, because it multiplies size through added whitespace.

Because JSON has no built-in object identity and no class metadata, a full round trip of a PHP object through json_encode()/json_decode() that ends up as an instance of the same class again requires an explicit contract, either the JsonSerializable interface controlling the encode direction, or a factory or hydrator that maps a decoded array back onto a typed object. That is a deliberate constraint, not a shortcoming, it is exactly what keeps JSON-based PHP serialization safe against object injection, covered in the next section.

4. Security risks of unserialize(): object injection and POP chains

The central risk of unserialize() is that the function does more than parse data, it can trigger code execution as a side effect of parsing untrusted input. As soon as the payload contains an O:len:"ClassName":... segment for a class implementing one of PHP's magic methods, __wakeup(), __destruct(), __toString(), or in modern PHP __unserialize(), unserialize() calls that method automatically, without the application code ever having instantiated the object explicitly. If an attacker controls the serialized string, for instance because it was accepted from a cookie, an uploaded file, or an API parameter, they effectively control which class gets instantiated and what its initial property values are.

This becomes exploitable through what security researchers call a property-oriented programming chain, or POP chain: the attacker does not need to inject new code at all, they only need classes that are already loaded somewhere in the application, a framework class, a logging class, a cache adapter, whose magic methods perform a dangerous operation, deleting a file, writing to a path, calling a method dynamically, when combined with attacker-controlled property values. A single class is rarely dangerous on its own, the risk emerges from a chain, one object's destructor calls a method on a nested object, whose own magic method does something else, until a sensitive operation is finally reached.

The deliberately abstract example below shows the mechanism in miniature: a class with a __wakeup() method that performs a side effect based on a property value. It intentionally does not target any specific real-world CVE, the point is to demonstrate that unserialize() calling __wakeup() automatically, purely as a consequence of parsing, is the fundamental hazard behind PHP object injection, regardless of which concrete gadget chain a real attack might assemble.


<?php

declare(strict_types=1);

// Illustrative example only: shows HOW unserialize() can trigger
// unwanted side effects, not a working exploit for any real CVE.
final class CacheFileHandle
{
    public string $path = '';
    public string $payload = '';

    // __wakeup() runs automatically whenever an instance of this
    // class is reconstructed by unserialize(), even if the
    // application never intended to create one explicitly.
    public function __wakeup(): void
    {
        // A "harmless-looking" convenience method: writes the payload
        // to the path stored in the object's own property.
        // If $path and $payload originate from attacker-controlled
        // serialized input, the attacker effectively chooses
        // WHERE something gets written and WHAT gets written.
        file_put_contents($this->path, $this->payload);
    }
}

// Application code never calls "new CacheFileHandle()" here at all.
// The dangerous call happens purely as a side effect of parsing:
$untrustedInput = $_COOKIE['cached_state'] ?? '';

// DANGEROUS: unserialize() without allowed_classes will instantiate
// ANY class named in $untrustedInput and call its magic methods.
$restored = unserialize($untrustedInput);

5. Hardening unserialize(): allowed_classes and safe alternatives

The single most effective mitigation is the allowed_classes option of unserialize(), introduced specifically to close the object injection attack surface. Passing an explicit array of permitted class names, or false to allow no objects at all, tells the engine to convert any O:... segment naming a class outside that allowlist into a __PHP_Incomplete_Class instance instead of instantiating and waking it up. This single option turns an unbounded remote-class-instantiation problem into a small, auditable allowlist.

A second layer of hardening is architectural: avoid calling unserialize() on untrusted input at all. If data genuinely needs to cross a trust boundary, cookies, uploaded files, third-party API responses, standardize on json_decode() for that boundary and reserve native serialize()/unserialize() strictly for data your own process wrote and reads back internally, such as an APCu cache entry. This separation, native format for trusted internal round trips, JSON for anything crossing a trust boundary, removes an entire class of PHP serialization related vulnerabilities by design, rather than by careful per-call configuration.


<?php

declare(strict_types=1);

// Hardened unserialize(): only these two classes may be instantiated,
// everything else becomes __PHP_Incomplete_Class instead of a live object.
$data = unserialize($untrustedInput, [
    'allowed_classes' => [CacheEntry::class, CacheMetadata::class],
]);

// Even stricter: allow no objects at all, only scalars and arrays.
$scalarOnly = unserialize($untrustedInput, ['allowed_classes' => false]);

// Safe-alternative pattern: never unserialize() untrusted input at all.
// Decode as JSON and hydrate explicitly through a typed factory instead.
function hydrateCacheEntry(string $json): CacheEntry
{
    /** @var array{key: string, value: string, ttl: int} $decoded */
    $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

    return new CacheEntry(
        key: $decoded['key'],
        value: $decoded['value'],
        ttl: $decoded['ttl'],
    );
}

6. Performance comparison: speed, memory footprint and payload size

Numbers matter more than intuition here. For simple scalar arrays, strings, integers, small nested structures, serialize()/unserialize() is generally faster than json_encode()/json_decode(), because the native format's length-prefixed encoding avoids the string scanning and UTF-8 validation that JSON parsing requires. That difference shrinks and can even reverse for deeply nested object graphs, where json_encode() combined with JsonSerializable is often competitive, because the native format's per-property overhead, the mangled property name markers, adds up.

Payload size tells a similar story from the opposite direction. JSON is generally more compact for simple arrays and scalars because it carries no class name and no explicit type letter for every value, native serialize() output for objects can be noticeably larger due to embedded class names and mangled property name prefixes repeated for every single property. For a cache entry stored millions of times, this size difference compounds into meaningful memory pressure in Redis or Memcached.

The benchmark script below illustrates the measurement approach: wrap both round trips in hrtime() calls, run enough iterations to smooth out noise, and compare timing and strlen() of the resulting payload side by side. Any real decision for a specific system should be based on this kind of repeated, environment-specific measurement rather than on generic benchmark numbers from a blog post, because the concrete data shape has more influence on the result than the choice of serializer alone.


<?php

declare(strict_types=1);

// Simple benchmark comparing native and JSON PHP serialization.
$data = [
    'id' => 48213,
    'name' => 'Sample Product',
    'tags' => ['php', 'serialization', 'benchmark'],
    'active' => true,
    'price' => 19.99,
];

$iterations = 100_000;

// --- Native serialize()/unserialize() ---
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $native = serialize($data);
    $restored = unserialize($native);
}
$nativeTimeMs = (hrtime(true) - $start) / 1_000_000;

// --- JSON encode()/decode() ---
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $json = json_encode($data, JSON_THROW_ON_ERROR);
    $restored = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
$jsonTimeMs = (hrtime(true) - $start) / 1_000_000;

printf("native: %.2f ms, %d bytes\n", $nativeTimeMs, strlen($native));
printf("json:   %.2f ms, %d bytes\n", $jsonTimeMs, strlen($json));

7. Data type pitfalls: objects, properties and JsonSerializable

Objects are where PHP serialization diverges most sharply between the two formats. Native serialize() preserves the full class identity and every property, including private and protected ones, because it operates at the engine level with direct memory access. json_encode() only serializes public properties of an object by default, private and protected properties are entirely invisible to it unless the class implements JsonSerializable and explicitly exposes them through jsonSerialize(). Overlooking this is a common source of bugs, a class with important business data in protected properties silently loses that data when passed through json_encode() without implementing the interface.

The __sleep() and __wakeup() magic methods let a class control its own native serialization lifecycle: __sleep() returns an array of property names that should actually be persisted, useful for excluding non-serializable resources like open database handles or file pointers, and __wakeup() runs after unserialize() to restore any state that could not survive the round trip, reopening a connection, revalidating a cached value. Modern PHP also offers __serialize() and __unserialize(), which operate on arrays rather than raw property names and work correctly with both native serialization and other engine-level mechanisms in a more explicit and testable way.

A final common trap is the stdClass versus associative array question after json_decode(). Passing true as the second argument returns nested associative arrays, convenient for quick access but without any notion of object identity or type. Passing false, or omitting the argument, returns stdClass instances, which support property access syntax but complicate type safety and IDE autocompletion. Most PHP serialization code operating on JSON in a typed PHP 8.4 codebase is better served by decoding to arrays and then hydrating explicit, typed value objects, rather than working with stdClass directly throughout the business logic.


<?php

declare(strict_types=1);

final class Invoice implements JsonSerializable
{
    public function __construct(
        private readonly string $number,
        private readonly float $total,
        private readonly string $internalNote, // deliberately excluded from JSON
    ) {
    }

    // Controls what json_encode() actually sees; private properties
    // are otherwise invisible to json_encode() by default.
    public function jsonSerialize(): array
    {
        return [
            'number' => $this->number,
            'total' => $this->total,
            // internalNote intentionally omitted from the API payload
        ];
    }

    // Controls what gets persisted by native serialize().
    public function __sleep(): array
    {
        return ['number', 'total', 'internalNote'];
    }

    // Runs after unserialize() rebuilds the object from native format.
    public function __wakeup(): void
    {
        // Example: re-validate invariants after restoring from cache.
        if ($this->total < 0) {
            throw new UnexpectedValueException('Invoice total cannot be negative');
        }
    }
}

8. When native serialization still makes sense, and when JSON is mandatory

Native serialize() still earns its place in purely internal, same-process-family contexts, where speed and full object fidelity, including private properties, matter more than portability. A PHP-only session handler backed by Redis, an APCu cache storing computed configuration objects, or a queue where producer and consumer are guaranteed to be the same PHP codebase in the same deployment are legitimate use cases where native PHP serialization remains a reasonable default, provided the data never crosses a trust boundary from outside the application.

JSON becomes close to mandatory the moment interoperability, security exposure to untrusted input, or human readability enter the equation. Any public or internal API, any log line meant to be grepped or ingested by a log aggregator, any payload handed to a JavaScript frontend, and any data that could conceivably originate from outside the current PHP process should use JSON, both because other languages and tools can read it natively and because json_decode() carries none of the object-instantiation risk of unserialize().

Criterion serialize() / unserialize() json_encode() / json_decode() Recommendation
Security with untrusted data Risk: object injection without allowed_classes Safe: no automatic object instantiation JSON for anything not fully trusted
Object support Complete, including private/protected properties Public properties only, unless JsonSerializable Native format for internal object graphs
Interoperability Only PHP understands the format Language-agnostic standard (RFC 8259) JSON for APIs and cross-language exchange
Payload size Often larger due to class names and property mangling Usually more compact for simple structures JSON for high-volume caches
Readability Barely readable, PHP-specific format Human readable, inspectable in any editor JSON for debugging and logs
Performance on simple arrays Usually faster, no UTF-8 parsing needed Usually slightly slower due to string validation Native format for performance-critical internal structures

The table reveals a consistent pattern: the moment a string could leave the application or enter it from outside, JSON almost always wins, regardless of whether the concern is security, interoperability, or readability. Native PHP serialization remains the right choice only where that boundary is demonstrably never crossed.

9. Migrating from serialize() to JSON in existing systems

Migrating existing, stored serialize() payloads, session data, cache entries, to JSON is rarely a single atomic cutover, because whatever wrote the old format is usually still running somewhere in a rolling deployment, and existing entries typically expire naturally through a TTL rather than being rewritten all at once. The practical strategy is a transition phase with dual read and single write: new writes always go out as JSON, reads first attempt json_decode() and fall back to the hardened legacy path through unserialize() with allowed_classes whenever the payload does not parse as valid JSON.

This fallback needs a reliable way to distinguish the two formats before attempting to parse, in almost every real case checking the first character is enough: a JSON payload always starts with {, [, ", a digit, t, f, or n, while a native serialize() payload always starts with one of the single-letter type markers followed by a colon, a:, O:, s:, i:, b:, d:, or N;. Combined with TTL-based expiry on cache entries, or a background migration job for longer-lived data like stored session snapshots, this dual-read window can be closed safely once monitoring shows the legacy code path is no longer being hit.

A short observability step closes the loop: instrument the fallback branch with a counter or log line so the team can watch the legacy-format hit rate trend toward zero over days or weeks, rather than guessing when it is safe to remove the unserialize() fallback entirely. Only once that counter has sat at zero for a full TTL cycle should the legacy branch actually be removed from the codebase.


#!/usr/bin/env bash
# migrate-cache-format.sh - Batch-migrates legacy serialize() cache entries to JSON.
set -euo pipefail

REDIS_CLI="redis-cli"
PATTERN="cache:entry:*"

# Iterate all matching keys and re-encode legacy payloads as JSON.
for key in $("$REDIS_CLI" --scan --pattern "$PATTERN"); do
  raw="$("$REDIS_CLI" GET "$key")"

  # Dual-read strategy: try JSON first, fall back to legacy serialize().
  if php -r '
    $raw = $argv[1];
    json_decode($raw);
    if (json_last_error() === JSON_ERROR_NONE) {
        exit(0); // already JSON, nothing to migrate
    }
    exit(1);
  ' "$raw"; then
    continue
  fi

  # Convert legacy payload to JSON via a hardened PHP helper script.
  json_payload="$(php migrate-entry.php --format=legacy -- "$raw")"
  "$REDIS_CLI" SET "$key" "$json_payload"
  echo "[MIGRATED] $key"
done

10. Summary

The central insight about PHP serialization is structural, not cosmetic: native serialize() encodes type, length, and class name directly in the string, which lets unserialize() instantiate any named class and trigger its magic methods, a behavior that is unproblematic with trusted, self-written data but a direct entry point for PHP object injection with untrusted data. JSON deliberately gives up this object identity and is therefore inherently immune to this attack class, at the cost of explicit work to hydrate objects through JsonSerializable or a factory.

Anyone who never calls unserialize() without allowed_classes on potentially untrusted data, consistently uses JSON for anything crossing a trust boundary, and limits native serialization to purely internal, same-process round trips closes the vast majority of practically relevant risks in PHP serialization. For existing systems with historically grown serialize() data, a dual-read migration phase with an observable fallback rate is the safest way to switch to JSON without a breaking change.

PHP Serialization: JSON vs. native, the key takeaways

Security

Never call unserialize() without allowed_classes on untrusted data. JSON is the safe default for anything outside your own trust boundary.

Format

serialize() encodes type, length, and class name directly in the string. JSON is language-agnostic and readable, but without native object identity.

Performance

Native format is often faster for simple arrays, JSON is usually more compact in payload size. Measurement beats intuition.

Migration

A dual-read strategy with JSON as the default and a hardened unserialize() fallback enables a gradual switch without a breaking change.

11. FAQ: PHP Serialization

1What is the difference between serialize() and json_encode()?
serialize() uses a PHP-specific format embedding class names and preserves private properties. json_encode() produces language-agnostic JSON and only serializes public properties by default.
2Is unserialize() dangerous?
Dangerous with untrusted input, because it automatically instantiates classes and calls magic methods. Unproblematic on your own internal data.
3What is PHP object injection?
A vulnerability where an attacker determines which class gets instantiated through controlled serialize() input. Combined with magic methods this forms a POP chain.
4How does allowed_classes protect against object injection?
Classes outside the given allowlist become __PHP_Incomplete_Class instead of real instances, and their magic methods are never called.
5Why does json_encode() lose some object properties?
Only public properties are serialized by default. Private and protected stay invisible unless JsonSerializable exposes them explicitly through jsonSerialize().
6When should I use JSON instead of native serialization?
Whenever data could cross a trust boundary or other languages need to read it. Native format only for purely internal round trips.
7Is JSON always faster than serialize()?
No. For simple arrays, native serialize() is often faster. For deeply nested objects, JSON can be competitive or faster.
8What does JSON_THROW_ON_ERROR do?
Turns encoding and decoding errors into a JsonException instead of silently returning false. Errors become visible in code review.
9How do I migrate existing serialize() data to JSON?
Use dual-read: always write JSON, try json_decode() first on read, fall back to hardened unserialize() on failure, until the fallback is no longer hit.
10__sleep()/__wakeup() vs. __serialize()/__unserialize()?
__sleep()/__wakeup() are the older, property-name-based pattern. __serialize()/__unserialize() work with arrays and are the more modern, more testable variant.

Mironsoft

PHP development, code reviews and security audits for Magento and PHP projects

Secure PHP serialization in your code stack?

We analyze existing PHP and Magento codebases for unsafe unserialize() calls, object injection risks, and inefficient cache formats, and guide the migration to safe, performant serialization strategies.

Security Audit

Systematic search for unsafe unserialize() calls and object injection attack surfaces in the code

Migration

Gradual migration of serialize() cache and session data to JSON without breaking changes

Performance

Benchmark-based recommendations for cache formats in Redis, APCu, and message queues