Back to Blog
Lesson 4 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 27, 20263 min read

Utilizing Data Transfer Objects (DTOs) for Type-Safe Laravel

Stop passing associative arrays through your application. Learn to use DTOs to enforce type safety and data integrity across your Laravel architecture.

LaravelDTOArchitectureDDDType SafetyPHPbackend

Previously in this course, we explored implementing action classes to encapsulate our business logic. While actions keep our controllers thin, passing loose associative arrays between them creates a "hidden contract" problem where types are guessed and keys are easily forgotten.

This lesson adds Data Transfer Objects (DTOs) to our architectural toolkit. By replacing unstructured arrays with strict, immutable objects, we gain compile-time confidence and runtime Data Integrity.

The Problem with Associative Arrays

In a high-traffic SaaS environment, an associative array is a liability. Consider a CreateSubscription action:

PHP
#6A9955">// The "Array Hell" approach
public function handle(array $data) {
    return Subscription::create([
        'user_id' => $data['user_id'], #6A9955">// What if this is missing?
        'plan_id' => $data['plan_id'], #6A9955">// Is this an int or a string?
    ]);
}

When you pass an array, you lose static analysis capabilities. You don't know if user_id is present, if it's the right type, or if the key was typoed as userid. By utilizing a DTO, we formalize the schema.

Creating Type-Safe DTOs

A DTO is a simple object whose only job is to carry data. In modern PHP, we use readonly properties and constructor promotion to keep them concise and immutable.

PHP
namespace App\DTOs;

readonly class CreateSubscriptionDTO
{
    public function __construct(
        public int $userId,
        public string $planId,
        public ?string $promoCode = null,
    ) {}

    public static function fromRequest(\Illuminate\Http\Request $request): self
    {
        return new self(
            userId: (int) $request->user()->id,
            planId: $request->validated('plan_id'),
            promoCode: $request->validated('promo_code'),
        );
    }
}

By defining this class, we ensure that any service receiving this DTO knows exactly what data is available. This is the foundation of Type Safety in our domain layer.

Validating Data Flow Across Services

To ensure our DTOs are always valid, we perform validation before instantiation. We use Laravel's FormRequest to handle the heavy lifting of input validation, then hydrate the DTO.

In our project, we are currently re-architecting our billing module. Instead of passing the request object into our ProcessPayment action, we now require a PaymentDTO.

PHP
#6A9955">// Inside a Controller
public function store(PaymentRequest $request)
{
    $dto = PaymentDTO::fromRequest($request);
    
    #6A9955">// The action now has a strict contract
    return $this->paymentAction->execute($dto);
}

This pattern ensures that by the time our domain logic runs, the data is already cleaned, cast, and validated.

Hands-on Exercise

  1. Define a DTO: Create a UserRegistrationDTO in app/DTOs that accepts email (string), name (string), and age (int).
  2. Implement Hydration: Add a static fromArray(array $data) method to your DTO that performs manual casting.
  3. Refactor an Action: Take an existing action that accepts an array and update the signature to accept your new UserRegistrationDTO.

Common Pitfalls

  • Over-engineering: Do not turn your DTOs into mini-models. They should not contain business logic or database interactions. If you find yourself adding methods to query the database, you've crossed the line into Entity territory.
  • Performance overhead: While creating objects has a negligible memory cost, avoid creating thousands of DTOs inside a tight loop if you are processing massive datasets.
  • Incomplete Hydration: Always ensure your DTOs are fully populated. If a property can be null, explicitly mark it as nullable (?string) rather than omitting it, to keep the contract clear.

Recap

We've moved from passing "blind" arrays to using strict, immutable DTOs. This change shifts our error surface from runtime (where things break in production) to development time (where IDEs and static analysis tools like PHPStan can catch our mistakes). We have enforced a strict data contract that will make our SaaS platform significantly easier to refactor as we scale.

Up next: Service Layer Pattern — we'll learn how to orchestrate these DTOs across multiple domains using dedicated service classes.

Similar Posts