DTOs — Replacing Magic Arrays with Typed Objects

DTOs — Replacing Magic Arrays with Typed Objects

Reading Time: 5 minutes

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 readonly properties
  • 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?

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() or update()
  • 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 readonly for 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: readonly class properties are PHP 8.2+. For PHP 8.1, use public readonly on individual properties. For PHP 8.0, use a constructor-assigned private property with a getter. The fromArray() 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


← Action Classes | Next: Eloquent Model Traits →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments