Eliminating DTO Boilerplate with PHP Readonly Properties
Stop writing tedious getters for your DTOs. Learn how PHP 8.2 readonly classes enforce immutability and eliminate boilerplate code.
15 Aug 2025, 00:02 UTC

The Problem: The 'Getter' Tax in Data Transfer Objects
When building Data Transfer Objects (DTOs)—simple objects used to pass data between layers of an application—PHP developers traditionally faced a choice: use public properties and risk accidental state mutation, or use private properties and write a tedious series of getter methods. This "getter tax" adds significant noise to the codebase without providing actual logic, making the code harder to scan and maintain.
The goal is immutability: once a DTO is created from a request or a database query, its data should not change. PHP 8.1 and 8.2 introduced readonly properties and classes to solve this by enforcing immutability at the language level while keeping the properties public.
Enforcing State Consistency
A readonly property can only be initialized once, and that initialization must happen within the class where the property is defined (typically the constructor). Once a value is assigned, any attempt to modify it results in a Error.
This removes the need for defensive coding. You no longer need to worry about a service accidentally changing a user ID or a price value halfway through a request lifecycle. Because the property is public, you can access it directly ($dto->id) instead of calling a method ($dto->getId()), reducing the cognitive load of the API.
Streamlining with Readonly Classes
In PHP 8.2, the language introduced readonly class. Instead of marking every individual property as readonly, marking the entire class automatically makes every property readonly. This is the ideal pattern for Value Objects and DTOs.
When using a readonly class, all properties must be typed. This ensures that the immutable data is not only protected from change but also conforms to the expected data type, providing a layer of runtime safety that complements static analysis tools.
Worked Example: Traditional vs. Readonly DTO
Consider a scenario where we need to pass a validated User Profile from a controller to a service layer. This example assumes PHP 8.2+.
The Traditional Approach (Boilerplate Heavy)
class UserProfileDTO {
private string $username;
private string $email;
public function __construct(string $username, string $email) {
$this->username = $username;
$this->email = $email;
}
public function getUsername(): string {
return $this->username;
}
public function getEmail(): string {
return $this->email;
}
}
The Modern Approach (Readonly Class)
By combining constructor property promotion with a readonly class, the same functionality is achieved in a fraction of the code:
readonly class UserProfileDTO {
public function __construct(
public string $username,
public string $email,
) {}
}
Verification and Risk
To verify this behavior, run the following in your PHP CLI:
$profile = new UserProfileDTO('dev_user', '[contact removed]');
echo $profile->username; // Works: dev_user
$profile->username = 'new_user'; // Throws Error: Cannot modify readonly property
Risk: Attempting to access a readonly property before it has been initialized (e.g., if it was not set in the constructor) will throw an Error. Always ensure all readonly properties are assigned during instantiation.
Trade-offs and Limitations
Readonly properties are not a complete replacement for all object patterns. The most significant limitation is that they cannot be "undone." If a child class inherits from a readonly class, it cannot change a property back to mutable.
Additionally, while the property itself cannot be reassigned, if the property holds a mutable object (like a standard stdClass or an array), the internal state of that object can still be changed. Readonly only prevents the property from pointing to a different object or value; it does not recursively make the assigned object immutable.
Actionable Closing
For any new DTOs or Value Objects in PHP 8.2+ projects, default to readonly class. It eliminates boilerplate, enforces a strict contract of immutability, and improves readability. If you are migrating an existing project, start by replacing private properties and getters in your simplest data objects to reduce the surface area of your codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.