PHP DTOs and Value Objects with Readonly Classes
Learn how to implement Data Transfer Objects and Value Objects in PHP using readonly classes.
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
- ✓Basic knowledge of PHP classes and objects
- ✓Understanding of constructor promotion
- ✓Familiarity with type declarations
- DTOs (Data Transfer Objects) are simple objects that carry data between processes, often without behavior.
- Value Objects are immutable objects that represent a value, with equality based on their properties.
- PHP 8.1 readonly classes make implementing both patterns cleaner and safer.
- Use DTOs for API requests/responses and Value Objects for domain concepts like Money or Email.
- Both promote immutability, type safety, and self-documenting code.
Think of DTOs as a sealed envelope used to pass a message from one department to another. You don't change the message inside; you just read it. Value Objects are like a dollar bill: its value is determined by its amount, not by which specific bill it is. If you have two $10 bills, they are interchangeable. Similarly, two Value Objects with the same properties are considered equal.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
In modern PHP applications, especially those following Domain-Driven Design or clean architecture, you often need to transfer data between layers or represent immutable concepts. Two patterns that help are Data Transfer Objects (DTOs) and Value Objects. DTOs are simple containers that move data between processes, like from a controller to a service or from an API to a client. They typically have no behavior. Value Objects, on the other hand, are immutable objects that represent a descriptive aspect of your domain, such as an Email, Money, or a Coordinate. They encapsulate logic and ensure validity.
PHP 8.1 introduced readonly properties and readonly classes, which make implementing these patterns much cleaner. A readonly class ensures all properties are readonly, meaning they can only be set once, typically in the constructor. This enforces immutability at the language level, reducing bugs and making your code easier to reason about.
In this tutorial, you'll learn how to create DTOs and Value Objects using readonly classes, with practical examples. We'll cover constructor promotion, named arguments, and how to handle serialization. You'll also see common pitfalls, debugging strategies, and production incidents. By the end, you'll be able to confidently use these patterns to write safer, more maintainable PHP code.
What are DTOs and Value Objects?
Data Transfer Objects (DTOs) are simple objects that carry data between processes. They have no behavior, only properties and maybe getters. Their purpose is to transfer data, often across boundaries like HTTP requests, database queries, or service layers. Value Objects, on the other hand, are immutable objects that represent a concept in your domain. They have equality based on their properties, not identity. For example, two Email objects with the same address are considered equal. Value Objects often contain validation logic to ensure they are always in a valid state.
In PHP, before readonly classes, you had to manually write getters and avoid setters to achieve immutability. With readonly classes, you can simply declare the class as readonly, and all properties become readonly automatically. This reduces boilerplate and enforces immutability at the language level.
Let's start with a simple DTO example: a UserDTO that transfers user data from an API to your application.
Creating Value Objects with Readonly Classes
Value Objects are more than just data carriers; they encapsulate domain logic. For example, an Email value object should validate the email format and provide methods to access parts of the email. With readonly classes, you can ensure that once created, the email cannot be changed.
Here's an example of a Value Object for an email address:
__serialize() and __unserialize() to preserve readonly properties.Working with Collections of DTOs and Value Objects
Often you need to work with lists of DTOs or Value Objects. For example, an API might return a list of users. You can create a collection class that holds an array of DTOs. With readonly classes, you can make the collection immutable as well.
Here's an example of a UserCollection that holds UserDTO objects:
__serialize() for caching.Serialization and Deserialization
DTOs and Value Objects often need to be serialized to JSON for API responses or stored in caches. PHP's readonly classes work well with json_encode if properties are public. However, deserialization can be tricky because you cannot set readonly properties after construction. You need to create a new instance from the serialized data.
One approach is to use a named constructor or a static factory method. Here's an example for the Email Value Object:
__serialize(). For deserialization, use static factory methods to create new instances.Comparing Value Objects
Value Objects should be compared by their properties, not by reference. PHP's == operator compares objects by properties if the class defines ? Actually, PHP does not have a built-in __equals() magic method. You need to implement your own comparison method. Alternatively, you can use the == operator which compares all properties recursively, but this may not be reliable for nested objects.__equals()
A common approach is to implement an equals() method:
equals() method.equals() method for Value Objects to compare by value. PHP's == operator works but may be less explicit.Best Practices and Common Pitfalls
When using readonly classes for DTOs and Value Objects, keep these best practices in mind:
- Keep DTOs simple: They should only contain public properties and no methods (except maybe serialization).
- Validate in Value Object constructors: Ensure the object is always valid.
- Avoid inheritance: Readonly classes cannot be extended if they are final. Prefer composition over inheritance.
- Use named arguments: They make construction clearer and avoid order issues.
- Be careful with arrays: Readonly classes don't make array contents immutable. Consider using immutable collections.
- Trying to set readonly properties after construction.
- Forgetting to validate in Value Objects.
- Using DTOs with behavior (they should be anemic).
- Overusing Value Objects for simple data that doesn't need encapsulation.
The Case of the Mutable Money
- Always make Value Objects immutable to prevent unintended mutations.
- Use readonly classes to enforce immutability at compile time.
- Be cautious about sharing mutable objects across different parts of the application.
- Consider using DTOs for data transfer and Value Objects for domain concepts.
__equals() or use a custom comparison method. Readonly classes don't automatically compare by value.__serialize() and __unserialize() methods. Readonly classes may need special handling for serialization.var_dump((new ReflectionClass($obj))->isReadonly());var_dump((new ReflectionProperty($obj, 'prop'))->isReadonly());| File | Command / Code | Purpose |
|---|---|---|
| UserDTO.php | readonly class UserDTO | What are DTOs and Value Objects? |
| Email.php | readonly class Email | Creating Value Objects with Readonly Classes |
| UserCollection.php | readonly class UserCollection | Working with Collections of DTOs and Value Objects |
| EmailSerialization.php | readonly class Email | Serialization and Deserialization |
| EmailComparison.php | readonly class Email | Comparing Value Objects |
| BestPractices.php | readonly class UserDTO | Best Practices and Common Pitfalls |
Key takeaways
Common mistakes to avoid
3 patternsMaking DTOs mutable by adding setters
Not validating Value Objects in the constructor
Using Value Objects for everything, even simple data
Interview Questions on This Topic
What is the difference between a DTO and a Value Object?
Frequently Asked Questions
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
That's Advanced PHP. Mark it forged?
3 min read · try the examples if you haven't