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.
Table of Contents
- 1. What PHP serialization means and what it is used for
- 2. Native serialization with serialize() and unserialize()
- 3. JSON serialization with json_encode() and json_decode()
- 4. Security risks of unserialize(): object injection and POP chains
- 5. Hardening unserialize(): allowed_classes and safe alternatives
- 6. Performance comparison: speed, memory footprint and payload size
- 7. Data type pitfalls: objects, properties and JsonSerializable
- 8. When native serialization still makes sense, and when JSON is mandatory
- 9. Migrating from serialize() to JSON in existing systems
- 10. Summary
- 11. FAQ
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()?
2Is unserialize() dangerous?
3What is PHP object injection?
4How does allowed_classes protect against object injection?
5Why does json_encode() lose some object properties?
6When should I use JSON instead of native serialization?
7Is JSON always faster than serialize()?
8What does JSON_THROW_ON_ERROR do?
9How do I migrate existing serialize() data to JSON?
10__sleep()/__wakeup() vs. __serialize()/__unserialize()?
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