Building Robust Data Transfer Objects in PHP 8.3
Data Transfer Objects (DTOs) are the unsung heroes of clean architecture. They act as strict contracts between the layers of your application, ensuring that data moving from your controller to your service (or from an API to your core) is valid, structured, and predictable.
In the past, PHP developers often relied on associative arrays—"mystery meat" structures where you had to guess the keys and hope the types were correct. With the evolution of PHP, particularly the features available in PHP 8.3, we can now build DTOs that are lean, immutable, and self-validating with almost zero boilerplate.
In this tutorial, we will build a production-ready UserRegistrationDTO that handles strict typing, immutability, and validation logic, leveraging the latest PHP 8.3 features.
Prerequisites
To follow along, you’ll need:
- PHP 8.3 installed on your machine.
- A basic understanding of Object-Oriented PHP.
- Composer (optional, for pulling in validation libraries if desired).
1. The Evolution: From Arrays to Typed Objects
Before diving into the modern syntax, it’s worth remembering why we are doing this. In most web applications, passing data often looks like this:
Image credit: Wikimedia Commons - DTOs are crucial for moving data safely between these architectural layers.
// The old way: Associative Arrays
function registerUser(array $data) {
// What is in $data? 'email'? 'e-mail'? 'username'?
// We have to check everything manually.
if (!isset($data['email'])) {
throw new Exception('Missing email');
}
}
This approach is fragile. If you mistype a key, the application breaks at runtime. PHP 8.3 solves this with Typed Properties and Constructor Property Promotion.
The Modern Baseline
Let's create our initial DTO using Constructor Property Promotion (introduced in PHP 8.0) and named arguments.
class UserRegistrationDTO
{
public function __construct(
public string $username,
public string $email,
public ?string $phoneNumber = null,
) {}
}
// Usage
$dto = new UserRegistrationDTO(
username: 'dev_jane',
email: '[email protected]'
);
Already, this is infinitely better. The IDE knows exactly what properties exist, and PHP ensures $email is always a string.
2. Immutability with readonly Classes
One of the core principles of a reliable DTO is immutability. Once a DTO is created, it shouldn't change. This prevents "spooky action at a distance" where a service modifies the data unexpectedly before saving it.
In PHP 8.1, we got readonly properties. In PHP 8.2, we got readonly classes. This is the standard in PHP 8.3 for DTOs.
Declaring a class as readonly automatically makes every property immutable. You cannot assign a value to them outside the constructor.
readonly class UserRegistrationDTO
{
public function __construct(
public string $username,
public string $email,
public ?string $phoneNumber = null,
public bool $marketingConsent = false,
) {}
}
Now, if you try to modify a property after instantiation:
$dto = new UserRegistrationDTO('jane', '[email protected]');
$dto->email = '[email protected]';
// Fatal Error: Cannot modify readonly property UserRegistrationDTO::$email
This guarantees data integrity throughout the lifecycle of the request.
3. The PHP 8.3 Game Changer: Modifiable Readonly Properties
Prior to PHP 8.3, readonly classes had a significant drawback: they were too rigid. If you wanted to "update" a DTO (e.g., sanitise an email address or normalise a phone number), you had to create a completely new instance manually, copying every single property.
PHP 8.3 introduced a subtle but powerful change: Readonly properties can now be re-initialised within the __clone magic method.
This enables the "Wither" pattern (similar to withContext or withState in other languages) while maintaining immutability.
Implementing the Wither Pattern
Let’s add a method to our DTO that allows us to create a copy with a normalised email.
readonly class UserRegistrationDTO
{
public function __construct(
public string $username,
public string $email,
public ?string $phoneNumber = null,
) {}
public function withEmail(string $newEmail): self
{
// In PHP 8.3, we can modify readonly properties on the *clone*
$clone = clone $this;
$clone->email = $newEmail;
return $clone;
}
}
// Usage
$original = new UserRegistrationDTO('jane', ' [email protected] ');
$clean = $original->withEmail(strtolower(trim($original->email)));
echo $original->email; // " [email protected] " (Unchanged)
echo $clean->email; // "[email protected]" (Modified copy)
This feature makes working with immutable DTOs in PHP 8.3 significantly more ergonomic, especially for pipelines where data is transformed step-by-step.
4. Validating Data Integrity
A DTO that holds invalid data (like an invalid email format) is a lying DTO. While you can use external libraries like symfony/validator or laravel/framework form requests, adding domain validation directly into the DTO ensures it can never exist in an invalid state.
Constructor Validation
The simplest way is to validate inside the constructor.
readonly class UserRegistrationDTO
{
public function __construct(
public string $username,
public string $email,
) {
if (strlen($this->username) < 3) {
throw new InvalidArgumentException("Username must be at least 3 chars.");
}
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email format.");
}
}
}
Pro Tip: Using Assertion Libraries
For cleaner code, I highly recommend using a library like webmozart/assert. It turns verbose if statements into readable one-liners.
composer require webmozart/assert
use Webmozart\Assert\Assert;
readonly class UserRegistrationDTO
{
public function __construct(
public string $username,
public string $email,
) {
Assert::minLength($username, 3);
Assert::email($email);
}
}
Now, you can't even instantiate the class with bad data. This is the concept of "Parse, don't validate"—if the object exists, it is valid by definition.
5. Handling Complex Types with Union and Intersection Types
PHP 8.3 shines when dealing with complex data structures. DTOs often need to handle polymorphic data (e.g., an ID that could be an integer or a UUID string).
Union Types (|)
Use Union Types when a property can be one of several types.
readonly class ProductDTO
{
public function __construct(
// Can be int OR string
public int|string $id,
public string $name
) {}
}
Intersection Types (&)
Use Intersection Types when an object must satisfy multiple interfaces. This is rare in simple DTOs but powerful in framework integrations.
public function __construct(
// Must implement BOTH Cacheable AND JsonSerializable
public Cacheable&JsonSerializable $payload
) {}
Disjunctive Normal Form (DNF) Types
PHP 8.2+ allows combining these. For example, a property that can be (A AND B) OR null.
public (HasTitle&HasId)|null $entity;
6. Serialization: Converting DTOs to JSON
Eventually, you'll need to send your DTO back to a frontend or an API client. By default, json_encode works on public properties, but readonly classes work perfectly with it.
However, sometimes you want to expose different keys (e.g., camelCase in PHP, snake_case in JSON). Implementing JsonSerializable gives you full control.
readonly class UserDTO implements JsonSerializable
{
public function __construct(
public string $firstName,
public string $lastName,
) {}
public function jsonSerialize(): array
{
return [
'full_name' => "{$this->firstName} {$this->lastName}",
'created_at' => date('c'), // Dynamic fields
];
}
}
echo json_encode(new UserDTO('John', 'Doe'));
// Output: {"full_name":"John Doe","created_at":"2023-10..."}
7. Real-World Scenario: A Complete User Registration Flow
Let’s put it all together. We will build a DTO that handles a registration request, normalises the email, validates the input, and is ready for persistence.
<?php
use Webmozart\Assert\Assert;
readonly class UserRegistrationRequest
{
public function __construct(
public string $username,
public string $email,
public string $password,
public ?string $inviteCode = null,
) {
// 1. Validation Logic
Assert::minLength($username, 3, 'Username too short');
Assert::email($email, 'Invalid email address');
Assert::minLength($password, 8, 'Password must be 8+ chars');
}
/**
* Creates a new instance with a normalised email.
* Demonstrates PHP 8.3 __clone behavior.
*/
public function withNormalizedEmail(): self
{
$clone = clone $this;
$clone->email = strtolower(trim($this->email));
return $clone;
}
/**
* static factory method for creating from an array (e.g., $_POST)
*/
public static function fromArray(array $data): self
{
return new self(
username: $data['username'] ?? '',
email: $data['email'] ?? '',
password: $data['password'] ?? '',
inviteCode: $data['invite_code'] ?? null,
);
}
}
// --- Simulating a Controller ---
try {
$rawInput = [
'username' => 'dev_mark',
'email' => ' [email protected] ',
'password' => 'secret_password_123',
'invite_code' => 'XYZ-999'
];
// 1. Creation & Validation
$request = UserRegistrationRequest::fromArray($rawInput);
// 2. Transformation (Immutable)
$cleanRequest = $request->withNormalizedEmail();
// 3. Usage
echo "Processing registration for: " . $cleanRequest->email . "\n";
// Output: Processing registration for: [email protected]
// Verify immutability
if ($request === $cleanRequest) {
echo "They are the same object.";
} else {
echo "They are different instances (Good!).";
}
} catch (InvalidArgumentException $e) {
echo "Error: " . $e->getMessage();
}
Why this is "Robust"
- Type Safety: You can't pass an array as a username.
- Guaranteed Validity: The object cannot exist if the email is invalid.
- Immutability: You can pass
$cleanRequestto 10 different listeners/services, and none of them can accidentally modify it. - Clarity: The code documents itself.
Pro Tips for PHP 8.3 DTOs
1. Anonymous Readonly Classes
If you need a quick, one-off DTO for an internal data transfer (e.g., returning multiple values from a private method), you can use anonymous readonly classes.
$result = new readonly class($user, $status) {
public function __construct(
public User $user,
public string $status
) {}
};
2. Using readonly with Dependency Injection
DTOs are often passed into services. Because they are strictly typed, you can use them for easy dependency injection wiring in frameworks like Symfony or Laravel (using Service Providers).
3. Benchmarking
While objects have a slight overhead compared to arrays, in PHP 8.3, the difference is negligible for most applications. The memory footprint of classes has been optimised significantly in recent versions. The developer experience and bug-reduction benefits far outweigh the nanoseconds of CPU time.
PHP 8.3 has cemented the language's transition from a loose, scripting-heavy tool to a strictly typed, robust enterprise language. By adopting readonly classes, constructor property promotion, and the new cloning capabilities, your Data Transfer Objects become powerful tools for enforcing business rules and ensuring data integrity.
Stop passing arrays around. Embrace the DTO pattern, and your future self (debugging a production issue 6 months from now) will thank you.