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
<?php use Attribute; /** * Marks a property or class as excluded from native serialization. * * When applied to a property, it is excluded from the serialized form in * both directions: skipped by serialize(), ignored by unserialize(). * When applied to a class, serialization and unserialization are forbidden. */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] final class NoSerialize {} ?>
Usage example:
<?php class Example { public string $name; #[NoSerialize] public PDO $connection; } $object = new Example(); $object->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 anException. - 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-serializablein their stub files (107 classes across 64 stubs) are migrated to#[NoSerialize]as part of this RFC, in the same way@deprecatedwas migrated to#[\Deprecated]in PHP 8.4. - The
@not-serializabletag remains supported bygen_stub.phpfor 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@deprecatedand#[\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
- 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).
Patches and Tests
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, matchingtransient/[NonSerialized])- Documented how the attribute interacts with
__unserialize()and__wakeup() - Stated explicitly that class-level
#[NoSerialize]also blocksunserialize(), 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.