PHP's Readonly is Not Full Immutability
PHP's introduction of the readonly keyword for class properties offers a powerful tool for developers aiming for immutability, particularly within Domain-Driven Design (DDD) and Value Objects. However, the keyword's name is a misnomer. readonly, by itself, only guarantees that a property cannot be reassigned after the object is constructed. It does not inherently provide value equality semantics or ensure that nested objects remain immutable. This distinction is critical and often leads to subtle bugs that only surface when objects are compared, stored, or passed between different parts of an application.
The common practice in PHP DDD tutorials is to define Value Objects as final readonly class and enforce validation within the constructor. While this ensures the object's properties cannot be directly mutated after creation, it overlooks two fundamental aspects of robust Value Objects: equality and deep immutability. These are separate contracts that PHP's readonly keyword does not automatically enforce.
Consider a Money Value Object. If you create two instances, $amount1 = new Money(100, 'USD'); and $amount2 = new Money(100, 'USD');, you intuitively expect them to be equal. Yet, a simple readonly declaration doesn't make $amount1 == $amount2 evaluate to true unless you explicitly implement an equals() method or rely on PHP's default object comparison, which checks for reference equality (meaning they are the same object in memory, not just holding the same values).
Similarly, if a Money object holds another object, perhaps a Currency object, the readonly property on the Money object itself doesn't prevent the internal Currency object from being mutated if it's not also immutable. This is where the 'trap' lies: developers assume readonly provides full immutability, leading to unexpected side effects when the object's internal state is altered indirectly.
The Equality Gap
Value Objects are defined by their attributes, not their identity. Two Value Objects with the same attributes should be considered equal. PHP's default object comparison (`==`) compares object identities (references), not their values. To achieve value equality, developers must explicitly implement a method, often named equals(), that compares all relevant properties of two objects. This method should be part of the Value Object's contract.
For instance, a Money Value Object might implement equals(self $other): bool. This method would check if $this->amount === $other->amount and $this->currency === $other->currency. If either of these nested properties were themselves mutable objects, the comparison would need to recursively call their respective equality methods or rely on their guaranteed immutability.
Without this explicit equality check, using Value Objects in collections (like arrays or custom collection classes) that rely on value comparison for operations like deduplication or searching will fail to behave as expected. An object might be added multiple times to a collection, or a search might not find an identical object because PHP treats them as distinct entities based on their memory addresses.

The Nested Immutability Problem
The immutability promised by readonly is shallow. It applies only to the direct properties of the class. If a readonly property holds another object, that nested object can still be mutated unless it is also immutable. This is particularly problematic in DDD when dealing with aggregates, which can contain multiple entities and Value Objects.
Consider an Order aggregate. It might contain a readonly property for a ShippingAddress Value Object. If ShippingAddress itself is not fully immutable (e.g., its properties like street or city are not readonly or are mutable objects themselves), then the Order object's ShippingAddress can be modified, breaking the aggregate's intended immutability. This can lead to states where an order appears to have been modified without any explicit operation on the Order object itself.
To ensure deep immutability, all Value Objects and Entities within an aggregate must be designed to be immutable. This means using readonly properties where appropriate, but also ensuring any nested objects are themselves immutable. For Value Objects, this typically involves using readonly for their properties and ensuring their constructors validate all necessary invariants. For nested objects that are themselves Value Objects, the same principle applies recursively.
Practical Implications for PHP Developers
The readonly keyword is a valuable addition to PHP's type system, but developers must understand its limitations. Relying solely on readonly for Value Objects and DDD aggregates is a common pitfall.
To build truly immutable Value Objects and aggregates in PHP:
- Implement Value Equality: Add an
equals()method to your Value Objects that performs a deep comparison of all their properties, recursively calling equality methods on nested Value Objects. - Ensure Deep Immutability: Verify that all properties, especially those holding other objects, are themselves immutable. This often means defining nested Value Objects also as
final readonly classeswith validated constructors. - Consider Serialization/Deserialization: When objects are serialized (e.g., to JSON, or via PHP's serialization) and then deserialized, the immutability guarantees must be re-established. Ensure your Value Objects handle this correctly, potentially by re-validating on deserialization or by ensuring the serialization process preserves immutability.
The readonly keyword is a step towards safer object modeling in PHP, but it's not the finish line. Developers must continue to explicitly design for value equality and deep immutability to avoid the subtle bugs that arise from this common misunderstanding.
