====== PHP RFC: Add #[NoSerialize] attribute for excluding properties or classes from serialization ======
* Version: 0.9
* Date: 2025-10-06
* Author: Dmytro Kulyk, lnkvisitor.ts@gmail.com
* Status: Under Discussion
* Implementation: https://github.com/php/php-src/pull/20074
===== Introduction =====
Serialization is a fundamental PHP mechanism that allows objects to be converted into a storable or transferable representation.
However, not every property of an object should necessarily be serialized. Frameworks and libraries often contain transient or resource-based properties—such as database connections, file handles, or caches—that should not be persisted.
Currently, developers must manually handle this by overriding __sleep() or __serialize(), which can lead to repetitive boilerplate and maintenance overhead.
This pattern is common in large codebases. A few representative examples:
^ Project ^ Pattern ^ What #[NoSerialize] replaces ^
| Magento 2 | __sleep() chains five levels deep, each doing array_diff(parent::__sleep(), [...]) to strip ~30 injected services | one attribute per transient property, no per-class __sleep() |
| Symfony | six identical __serialize() implementations whose only body throws an exception | class-level #[NoSerialize] |
| Laravel, Doctrine ORM | whitelist __sleep() returning two of N properties | marking the transient properties instead of enumerating the persistent ones |
Note that __serialize() is not a substitute here: it requires describing the //entire// format by hand, whereas the attribute expresses a single exception to the default format.
===== Proposal =====
This proposal introduces a new #[NoSerialize] attribute that can be applied to properties to exclude them from native PHP serialization or to classes to forbid serialization entirely.
It provides a declarative alternative to manually filtering properties within __sleep() or __serialize(), making serialization rules easier to maintain and more self-documenting.
==== 1. Syntax and Definition ====
Usage example:
name = "User";
$object->connection = new PDO('sqlite::memory:');
echo serialize($object);
// Serialized output will be `O:7:"Example":1:{s:4:"name";s:4:"User";}`.
?>
==== 2. Semantics ====
=== 2.1 Property-level Behavior ===
When the #[NoSerialize] attribute is applied to a **property**, it declares that the property is not part of the serialized form of the object, in either direction.
* When serialize() is invoked and the class does not define its own __serialize() or __sleep(), properties marked with #[NoSerialize] are skipped automatically.
* The resulting serialized data omits these properties entirely.
* When unserialize() encounters such a property in the payload — for example, in data produced by an older version of the class, or by a custom serializer — the value is parsed and discarded, and the property is left untouched. No warning is emitted, since this is precisely the migration case the attribute is expected to handle.
* Properties that are not restored keep their declared default value, or remain uninitialized if they have no default.
This symmetry matches the meaning of ''transient'' in Java and ''[NonSerialized]'' in C#: the field is excluded from the serialized representation, not merely from the writing side of it.
It also avoids an injection surface: without it, a property that the class author has explicitly declared to be outside the serialized form could still be populated from an attacker-controlled payload.
class SessionWrapper
{
public string $id;
#[NoSerialize]
public mixed $resource; // transient field
public function __construct()
{
$this->resource = fopen('php://memory', 'r+');
}
}
$s = new SessionWrapper();
$s->id = 'abc';
var_dump(unserialize(serialize($s)));
/*
object(SessionWrapper)#2 (1) {
["id"]=>
string(3) "abc"
}
*/
== Interaction with the serialization magic methods ==
If a class defines its own serialization logic via __serialize() or __sleep(), the #[NoSerialize] attribute on properties has **no effect**.
These methods are entirely user-defined, and PHP does not automatically filter out properties marked with #[NoSerialize].
This design maintains explicit and consistent behavior with existing PHP semantics — developer-defined serialization always takes precedence.
The same applies to the reading side. __unserialize() receives the payload as a plain array, exactly as written, including any entry whose name matches a property marked with #[NoSerialize]; the engine does not inspect it.
__wakeup() is different, because it is a post-processing hook rather than a format definition: properties are assigned by the engine first — and therefore filtered — and the hook runs afterwards on the resulting object.
^ Path ^ Attribute applied? ^
| Default serialization and deserialization | yes |
| __serialize() / __sleep() | no — the return value is used as-is |
| __unserialize() | no — the payload array is passed through untouched |
| __wakeup() | yes — properties are filtered before the hook is invoked |
| Serializable, or an internal class with a custom serializer | no |
Example:
class Custom
{
public string $a = 'A';
#[NoSerialize]
public string $b = 'B';
public function __serialize(): array
{
return ['a' => $this->a, 'b' => $this->b];
}
}
echo serialize(new Custom());
// Output still contains both 'a' and 'b'
Developers who wish to respect #[NoSerialize] inside __serialize() can do so manually via reflection:
class Custom
{
public string $a = 'A';
#[NoSerialize]
public string $b = 'B';
public function __serialize(): array
{
$result = [];
foreach ((new ReflectionObject($this))->getProperties() as $prop) {
if (!$prop->getAttributes(NoSerialize::class)) {
$result[$prop->getName()] = $prop->getValue($this);
}
}
return $result;
}
}
== Inheritance and Traits: ==
* #[NoSerialize] applied to a property affects only that declaration and is not inherited if a subclass redeclares the property.
* Properties introduced via traits preserve the attribute when composed into a class.
* Promoted constructor properties can include the attribute as usual.
class Example
{
public function __construct(
public string $name,
#[NoSerialize]
public ?PDO $db = null
) {}
}
=== 2.2 Class-level Behavior ===
When the #[NoSerialize] attribute is applied to a **class**, any attempt to serialize //or unserialize// an instance of that class throws an Exception, explicitly forbidding it.
This behavior uses the same internal mechanism as for built-in non-serializable classes (such as Random\Engine\Secure or CurlHandle), and therefore the same exception: the base \Exception class, with code 0, and the messages Serialization of 'ClassName' is not allowed and Unserialization of 'ClassName' is not allowed. The exception is catchable; it is not a fatal error.
This ensures that invalid or unintended serialization attempts are immediately visible to developers and cannot result in partial or lossy data structures.
#[NoSerialize]
class Connection
{
public PDO $pdo;
public function __construct()
{
$this->pdo = new PDO('sqlite::memory:');
}
}
class Wrapper
{
public string $name = 'foo';
public Connection $conn;
public function __construct()
{
$this->conn = new Connection();
}
}
$w = new Wrapper();
echo serialize($w);
/*
Fatal error: Uncaught Exception: Serialization of 'Connection' is not allowed
*/
Notes:
* Class-level #[NoSerialize] forbids serialization entirely by throwing an Exception.
* It can be used to mark classes that represent resources, handles, or runtime-only objects.
* This provides a clear and consistent failure mode, identical to the mechanism used for internal non-serializable classes.
== Integration with internal classes: ==
* All internal classes currently marked with @not-serializable in their stub files (107 classes across 64 stubs) are migrated to #[NoSerialize] as part of this RFC, in the same way @deprecated was migrated to #[\Deprecated] in PHP 8.4.
* The @not-serializable tag remains supported by ''gen_stub.php'' for third-party extensions and keeps setting the same engine flag, but it does not synthesise the attribute. The relationship is one-way: #[NoSerialize] implies the flag, the tag does not imply the attribute. This mirrors @deprecated and #[\Deprecated] today.
* This gives internal and userland classes the same reflection view of non-serializable behaviour.
== Inheritance: ==
* Class-level #[NoSerialize] is inherited by all child classes and cannot be “overridden”.
The prohibition is permanent (“sticky”) and automatically propagated throughout the inheritance chain.
* Applying #[NoSerialize] again in a subclass when the parent already has it is a no-op (but allowed).
=== 2.3 Interaction with other serialization forms ===
* Out of scope: JSON (json_encode(), JsonSerializable) and var_export() remain unaffected.
* The attribute affects only native PHP serialization (serialize(), unserialize()).
Future changes extending the behavior of #[NoSerialize] to json_encode() or other formats would be **backward-incompatible** once this RFC is implemented.
For that reason, any future proposal in this direction would need to introduce a **separate attribute**, such as #[NoJsonEncode], to avoid ambiguity and preserve expected behavior for existing code.
=== 2.4 Reflection API ===
The attribute is visible and queryable via reflection:
$rp = new ReflectionProperty(Example::class, 'connection');
var_dump($rp->getAttributes(NoSerialize::class)); // array(1) { ... }
=== 2.5 Invalid Targets & Compile-Time Diagnostics ===
Applying #[NoSerialize] to unsupported targets results in compile-time diagnostics.
The engine validates the attribute’s target during class compilation and emits appropriate compile-time errors.
^ Target ^ Severity ^ Message ^ Behavior ^
| Static property | **E_COMPILE_ERROR** | `Cannot apply #[\NoSerialize] to static property %s::$%s` | Compilation aborted |
| Virtual property | **E_COMPILE_ERROR** | `Cannot apply #[\NoSerialize] to virtual property %s::$%s` | Compilation aborted |
| Interface | **E_COMPILE_ERROR** | `Cannot apply #[\NoSerialize] to interface %s` | Compilation aborted |
| Trait | **E_COMPILE_ERROR** | `Cannot apply #[\NoSerialize] to trait %s` | Compilation aborted |
Rationale:
* Static properties are class-level and not part of instance serialization.
* Virtual properties are engine-managed and not serialized by userland mechanisms.
* Interfaces and traits cannot be serialized or instantiated, so the attribute is invalid in those contexts.
==== 3. Alternative names ====
^ Proposed name ^ Notes ^
| NoSerialize | Chosen for its clarity and consistency. Short, imperative, and self-explanatory. |
| SkipSerialize | Grammatically clear and intuitive; “skip” emphasizes runtime behavior rather than prohibition. Could be a valid alternative if “NoSerialize” is considered stylistically inconsistent. |
| SerializeIgnore | Mirrors conventions used in other languages and frameworks (e.g., @JsonIgnore in Java). However, it feels less idiomatic in PHP, which favors simple verb prefixes (No*, Allow*, etc.) over noun-based ones. |
| DoNotSerialize | Verbose but explicit. Deemed unnecessarily long for PHP attribute syntax. |
===== Backward Incompatible Changes =====
Defining a userland class named NoSerialize in the global namespace will no longer be possible, as this name becomes reserved for the new attribute.
A GitHub search for "class NoSerialize " language:php returned **11 results**, all defined within namespaces.
Therefore, this change would not affect any known public codebases in practice, and the impact on backward compatibility is expected to be negligible.
===== Proposed PHP Version(s) =====
Next version of PHP (PHP 8.6 or PHP 9.0)
===== RFC Impact =====
==== To the Ecosystem ====
This RFC has no negative impact on existing code and benefits frameworks, static analyzers, and serializers that rely on native PHP serialization.
All internal classes currently marked as @not-serializable in stub files are migrated to the #[NoSerialize] attribute as part of this RFC.
This ensures consistency between engine-level metadata and reflection-based tooling.
It is recommended that userland code and extensions use the #[NoSerialize] attribute rather than relying on documentation-only annotations.
==== To Existing Extensions ====
This RFC introduces no breaking changes for existing extensions.
Extensions that already rely on the @not-serializable annotation keep working unchanged: the tag stays supported and keeps setting the same engine flag. It does not synthesise the attribute, so reflection on those classes will not report #[NoSerialize] until the extension adopts it in its own stubs.
Adopting the #[NoSerialize] attribute directly in extension-defined stubs is optional but recommended for consistency with internal and userland code.
==== To SAPIs ====
None
===== Open Issues =====
None currently.
===== Future Scope =====
* Allow __sleep() to return null or no value, signaling the engine to fall back to the default serialization logic, which would then automatically respect #[NoSerialize].
===== Voting Choices =====
As this is a language change, a 2/3 majority is required.
Voting starts TBD and ends TBD (two weeks later).
* Yes
* No
===== Patches and Tests =====
[[https://github.com/php/php-src/pull/20074]]
===== Implementation =====
After the RFC is implemented, this section should contain:
- the version(s) it was merged into
- a link to the git commit(s)
- a link to the PHP manual entry for the feature
===== References =====
* Discussion: https://news-web.php.net/php.internals/128988
===== Rejected Features =====
* Class-level #[NoSerialize] with replacing value by NULL
* Asymmetric behaviour, where unserialize() restores a property marked with #[NoSerialize] if it is present in the payload
===== Changelog =====
* v0.9 — 2026-09-07
* Added ecosystem examples to the Introduction (Magento 2, Symfony, Laravel, Doctrine ORM)
* Internal classes are migrated to the attribute explicitly (as done for #[\Deprecated] in 8.4); the @not-serializable tag remains supported for third-party extensions but no longer implies the attribute
* unserialize() now skips properties marked with the attribute instead of restoring them (symmetric semantics, matching ''transient''/''[NonSerialized]'')
* Documented how the attribute interacts with __unserialize() and __wakeup()
* Stated explicitly that class-level #[NoSerialize] also blocks unserialize(), and specified the exception thrown (base \Exception, catchable); corrected the example output
* Fixed the property-level example: an untyped property has an implicit NULL default, so the sample uses mixed
* v0.8 — Added automatic #[NoSerialize] annotation for internal classes marked as @not-serializable in stubs
* v0.7 — Class-level behavior aligned with @not-serializable (now throws instead of serializing as NULL)
* v0.6 — Class-level #[NoSerialize] excluded from RFC
* v0.5 — Clarified JSON scope
* v0.4 — Restructured semantics into property/class sections, added proper compile-time diagnostics, and clarified deserialization behavior.