DTOs — Replacing Magic Arrays with Typed Objects
Series: Every Laravel Project Should Have These Building Blocks
Part: 10 of 35 | Level: Intermediate | Prerequisites: Action Classes
What You’ll Learn
- What a DTO is and the problem it solves
- How to build DTOs with PHP 8.1
readonlyproperties - The
fromArray()factory pattern for creating DTOs from request data - When to use a DTO vs. an Eloquent model
- How DTOs improve static analysis, testing, and refactoring
The Problem: Magic Arrays Everywhere
Here’s code most Laravel developers have written:
// In the controller
$data = $request->validated();
$this->orderService->create($data);
// In the service
public function create(array $data): Order
{
// What's in $data? Is 'first_name' there? Is it 'firstName'?
$order = Order::create([
'first_name' => $data['first_name'], // KeyError?
'email' => $data['email'],
'gateway' => $data['payment_gateway'], // Is this the right key?
]);
}
The $data array is a black box. Any key might or might not be there. The type is array<string, mixed> — which tells you nothing useful. Rename a key in the Form Request and you get a runtime error, not a compile-time error.
DTOs fix this.
What Is a DTO?
A Data Transfer Object is an immutable, typed object that carries data between layers of your application. It has no methods other than a constructor (and sometimes factory helpers). It’s not a model, not a service — just data with names and types.
// Instead of array $data...
final readonly class OrderCustomerData
{
public function __construct(
public string $firstName,
public string $lastName,
public string $email,
public PaymentGateway $paymentGateway,
public PaymentMethodType $paymentMethodType,
public ?string $address = null,
public ?string $city = null,
public ?string $postalCode = null,
public ?string $paymentReferenceId = null,
) {}
}
Now your service signature looks like this:
public function create(OrderCustomerData $data): Order
{
$order = Order::create([
'first_name' => $data->firstName, // IDE knows this exists
'email' => $data->email, // type-checked
'payment_gateway' => $data->paymentGateway->value, // it's an Enum!
]);
}
Building DTOs with PHP 8.1+ readonly
PHP 8.1 introduced readonly properties. PHP 8.2 introduced readonly classes. Both prevent properties from being modified after construction:
// PHP 8.1 — readonly properties
final class ServiceCheckResult
{
public function __construct(
public readonly string $name,
public readonly string $icon,
public readonly ServiceStatus $status,
public readonly string $message,
public readonly ?float $responseTimeMs = null,
) {}
}
// PHP 8.2 — readonly class (all properties are readonly automatically)
final readonly class ServiceCheckResult
{
public function __construct(
public string $name,
public string $icon,
public ServiceStatus $status,
public string $message,
public ?float $responseTimeMs = null,
) {}
}
The final readonly combination is the gold standard for DTOs. final prevents inheritance. readonly prevents mutation. Immutability makes DTOs safe to pass around without worrying about side effects.
The fromArray() Factory Method
DTOs often need to be created from raw input (validated form data, API responses, webhook payloads). The fromArray()factory method handles the conversion — including type coercion and defaults:
final readonly class OrderCustomerData
{
public function __construct(
public string $firstName,
public string $lastName,
public string $email,
public PaymentGateway $paymentGateway,
public PaymentMethodType $paymentMethodType,
public ?string $address = null,
public ?string $paymentReferenceId = null,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
// Handle edge cases: missing keys, wrong types, unknown enum values
$gateway = PaymentGateway::tryFrom((string) ($data['payment_gateway'] ?? ''))
?? PaymentGateway::Unknown;
$methodType = PaymentMethodType::tryFrom((string) ($data['payment_method_type'] ?? ''))
?? PaymentMethodType::Unknown;
$referenceId = empty($data['payment_reference_id'])
? null
: (string) $data['payment_reference_id'];
// If there's a reference ID but no gateway, infer Stripe
if ($referenceId !== null && $gateway === PaymentGateway::Unknown) {
$gateway = PaymentGateway::Stripe;
}
return new self(
firstName: (string) ($data['first_name'] ?? ''),
lastName: (string) ($data['last_name'] ?? ''),
email: (string) ($data['email'] ?? ''),
paymentGateway: $gateway,
paymentMethodType: $methodType,
address: $data['address'] ?? null,
paymentReferenceId: $referenceId,
);
}
}
The fromArray() method is the only place in your codebase that deals with the messiness of raw arrays. Once you have the DTO, everything downstream is clean and typed.
In the controller:
public function store(StoreOrderRequest $request): RedirectResponse
{
$data = OrderCustomerData::fromArray($request->validated());
$order = $this->createOrder->execute($request->user(), $data);
return redirect()->route('orders.show', $order);
}
Multiple Factory Methods
For complex DTOs, provide multiple factory methods for different sources:
final readonly class WhmAccountData
{
public function __construct(
public string $username,
public string $domain,
public string $password,
public string $plan,
public string $contactEmail,
) {}
// From a validated Form Request
public static function fromRequest(array $data): self
{
return new self(
username: $data['username'],
domain: $data['domain'],
password: $data['password'],
plan: $data['plan'],
contactEmail: $data['contact_email'],
);
}
// From a WHM API webhook payload (different key names)
public static function fromWhmPayload(array $payload): self
{
return new self(
username: $payload['user'],
domain: $payload['main_domain'],
password: $payload['pass'],
plan: $payload['package'],
contactEmail: $payload['contactemail'],
);
}
// From an Eloquent model
public static function fromCpanelAccount(CpanelAccount $account): self
{
return new self(
username: $account->username,
domain: $account->domain,
password: $account->generateTemporaryPassword(),
plan: $account->plan->slug,
contactEmail: $account->user->email,
);
}
}
DTO vs. Eloquent Model
Use an Eloquent model when the data is persisted and you need Eloquent features (relationships, scopes, observers, events).
Use a DTO when:
- You’re passing data to a model’s
create()orupdate() - You’re receiving data from an external API and haven’t persisted it yet
- You’re aggregating data from multiple models into one typed object
- You’re passing data across a boundary (HTTP → Service → Action) and want type safety
// DTO used as input to Action — not persisted yet
$data = OrderCustomerData::fromArray($request->validated());
$order = $this->createOrder->execute($user, $data);
// Once persisted, the Order Eloquent model takes over
return new OrderResource($order); // $order is an Eloquent model here
DTOs for Status Checks
The neurax-host project uses DTOs for the status page — the ServiceCheckResult DTO carries the result of each health check:
final readonly class ServiceCheckResult
{
public function __construct(
public string $name,
public string $icon,
public ServiceStatus $status,
public string $message,
public ?float $responseTimeMs = null,
) {}
}
// In a health check
final class DatabaseCheck implements StatusCheckInterface
{
public function run(): ServiceCheckResult
{
$start = microtime(true);
try {
DB::connection()->getPdo();
$ms = round((microtime(true) - $start) * 1000, 1);
return new ServiceCheckResult(
name: 'Database',
icon: 'fa-database',
status: ServiceStatus::Operational,
message: 'Connected',
responseTimeMs: $ms,
);
} catch (Throwable) {
return new ServiceCheckResult(
name: 'Database',
icon: 'fa-database',
status: ServiceStatus::Outage,
message: 'Connection failed',
);
}
}
}
The DTO makes the return type of run() unambiguous. Any code that receives a ServiceCheckResult knows exactly what fields are available.
PHPDoc Shape Annotations
When you can’t avoid arrays (legacy code, or arrays of DTOs), use PHPDoc shape annotations so static analysis tools can verify them:
/**
* @param array{
* first_name: string,
* last_name: string,
* email: string,
* payment_gateway: string,
* } $data
*/
public static function fromArray(array $data): self
{
// Larastan now understands the shape of $data
}
Key Takeaways
- DTOs replace magic arrays with typed, immutable objects. They make bugs compile-time errors instead of runtime surprises.
- Use
final readonlyfor maximum immutability and clarity. - Provide a
fromArray()factory method to handle conversion from raw input. Keep the messiness of type coercion in one place. - DTOs are for passing data between layers — not for persistence. Eloquent models handle persistence.
- PHPDoc shape annotations (
array{key: type}) are a lightweight alternative when DTOs would be overkill.
Frequently Asked Questions
Isn’t a DTO just an array with extra steps? Yes — and that’s exactly why it’s valuable. Arrays have no types, no IDE autocompletion, and can carry any keys silently. A DTO gives you string $firstName (not nullable unless you say so), IDE autocompletion on every property, and a clear contract. When fromArray() fails because a key is missing, the error pinpoints exactly what went wrong.
Should DTOs be immutable? Yes. In PHP 8.2+, add readonly to the class declaration. In PHP 8.1, mark each property readonly. A DTO that can be mutated after creation introduces the same bugs as a mutable array — if something needs to change, create a new DTO from the updated data.
Where do DTOs live if they’re used across Services and Actions? app/DTOs/. They belong there because they’re data definitions, not business logic. DTOs can be organized by domain: app/DTOs/Order/, app/DTOs/User/, etc., when the count grows.
Tips and Gotchas
⚠️ Warning: Don’t use DTOs as a substitute for validation. A DTO is a typed container for already-validated data. Never construct a DTO directly from raw
$request->all()— always pass$request->validated()first.
💡 Tip:
readonlyclass properties are PHP 8.2+. For PHP 8.1, usepublic readonlyon individual properties. For PHP 8.0, use a constructor-assigned private property with a getter. ThefromArray()factory pattern works the same in all three.
🔥 Expert Note: DTOs should cross layer boundaries in one direction: from the HTTP layer inward (controller → action → service). Never pass a DTO back out through the HTTP layer — that’s what API Resources are for. DTOs carry input; Resources shape output.
Further Reading
- PHP Docs: Readonly Classes (PHP 8.2+)
- PHP Docs: Readonly Properties (PHP 8.1+)
- Laravel Docs: Eloquent Resources — DTOs for input, Resources for output
2 Comments