====== 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.