What is a data transfer object (DTO)?

A Data Transfer Object  (DTO) is a design pattern used to transfer data between software application subsystems or layers. DTOs are simple objects that contain no business logic and serve purely as data carriers. They encapsulate multiple data attributes into a single object, making data transfer more efficient and organised.


Think of a DTO as a specialized container that holds related data together, similar to how a shipping box contains multiple items for transport. The key characteristics of DTOs include:

  • No Business Logic: DTOs only hold data; they don't perform operations
  • immutability: Once created, they typically don't change
  • Serializable: Can be easily converted to/from different formats (JSON, XML, etc.)
  • Type safety: Provide compile-time checking and IDE support

The DTO pattern was first introduced by Martin Fowler in his book "Patterns of Enterprise Application Architecture" (2002). Originally designed for Enterprise Java applications to reduce network calls in distributed systems. , DTOs solved the problem of "chatty" interfaces where multiple small requests were made instead of fewer, more substantial ones.

Why DTOs Matter in Laravel

Laravel applications often suffer from several common problems that DTos can elegantly solve:

Problem 1: Array Hell

// Without DTOs - unclear data structure
public function createUser(array $userData): User
{
    // What fields are in $userData? 
    // What types are they?
    // Which are required?
    return User::create($userData);
}

Problem 2: Controller Bloat

// Fat controllers doing validation and transformation
public function store(Request $request)
{
    $validated = $request->validate([
        'name' => 'required|string',
        'email' => 'required|email',
        'birth_date' => 'required|date',
        // ... 20 more fields
    ]);
    
    // Complex transformation logic
    $userData = [
        'name' => $validated['name'],
        'email' => strtolower($validated['email']),
        'birth_date' => Carbon::parse($validated['birth_date']),
        // ... more transformations
    ];
    
    return $this->userService->createUser($userData);
}

Problem 3: Inconsistent API Response

// Different endpoints returning different data formats
Route::get('/users/{id}', function($id) {
    return User::find($id); // Returns all model attributes
});

Route::get('/users/{id}/summary', function($id) {
    $user = User::find($id);
    return [
        'id' => $user->id,
        'name' => $user->name,
        'email' => $user->email,
        // Inconsistent with above endpoint
    ];
});

 

DTOs solve these problems by providing structure, type safety, and consistency.

Creating Your First DTO in Laravel

Let's start with a basic DTO implementation and gradually build complexity.

Basic DTO structure

<?php

namespace App\DTOs;

class UserDTO
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly ?string $phone = null,
        public readonly ?Carbon $birthDate = null,
    ) {}
    
    public static function fromArray(array $data): self
    {
        return new self(
            name: $data['name'],
            email: $data['email'],
            phone: $data['phone'] ?? null,
            birthDate: isset($data['birth_date']) 
                ? Carbon::parse($data['birth_date']) 
                : null,
        );
    }
    
    public function toArray(): array
    {
        return [
            'name' => $this->name,
            'email' => $this->email,
            'phone' => $this->phone,
            'birth_date' => $this->birthDate?->toDateString(),
        ];
    }
}

Why we structured it this way:

  • Readonly properties:  Prevents accidental mutation after creation, ensuring data integrity
  • Names constructor parameters: PHP 8+ syntax makes instantiation clear and prevents parameter order mistakes
  • fromArray() static method: Provides a clean factory method for creating DTOs from request data or database results
  • Type hints: string, ?string, ?Carbon provides compile-time checking and IDE autocomplete
  • Null coalescing: Handles optional fields gracefully with sensible defaults
  • toArray() method: Converts DTO back to array format for database operations or JSON responses

Using DTOs in Controllers

<?php

namespace App\Http\Controllers;

use App\DTOs\UserDTO;
use App\Http\Requests\CreateUserRequest;
use App\Services\UserService;

class UserController extends Controller
{
    public function __construct(
        private UserService $userService
    ) {}
    
    public function store(CreateUserRequest $request)
    {
        $userDTO = UserDTO::fromArray($request->validated());
        
        $user = $this->userService->createUser($userDTO);
        
        return response()->json([
            'data' => $userDTO->toArray(),
            'message' => 'User created successfully'
        ], 201);
    }
}

Why is this approach better?

  • Dependency Injection: User service is injected, making the controller testable and following the SOLID principles
  • Thin controller: Controller only handles HTTP concerns - converting request to DTO and returning response
  • Type Safety: CreateUserRequest handles validation, and UserDTO ensures type safety for the server layer
  • consistent response format: user toArray() ensures consistent API responses
  • Separation of concerns: Business logic stays in the service layer, not the controller

Service Layer Integration

<?php

namespace App\Services;

use App\DTOs\UserDTO;
use App\Models\User;

class UserService
{
    public function createUser(UserDTO $userDTO): User
    {
        return User::create($userDTO->toArray());
    }
    
    public function updateUser(User $user, UserDTO $userDTO): User
    {
        $user->update($userDTO->toArray());
        return $user->fresh();
    }
}


Why these service patterns work well:

  • Clear method signaturesUserDTO $userDTO parameter makes it immediately clear what data is expected
  • No array guessing: Service methods don't need to validate  or guess array structure
  • Reusable: The same DTO can be used for create, update or other operations
  • Clean Separation: Service handles business logic, DTO handles data transport

Common Pitfalls

1. Adding Business logic to DTOs

// Don't do this
class UserDTO
{
    public function createUser(): User
    {
        // Business logic doesn't belong in DTOs
        return User::create($this->toArray());
    }
}

// Do this instead
class UserService
{
    public function createUser(UserDTO $userDTO): User
    {
        return User::create($userDTO->toArray());
    }
}

2. Making DTOs Mutable

// Avoid mutable DTOs
class UserDTO
{
    public string $name;
    public string $email;
    
    public function setEmail(string $email): void
    {
        $this->email = $email; // Mutations make DTOs unreliable
    }
}

// Use immutable DTOs
class UserDTO
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
    ) {}
    
    public function withEmail(string $email): self
    {
        return new self($this->name, $email); // Return new instance
    }
}

3. Over-engineering with Too Many DTOs

// Don't create DTOs for every small data structure
class UserNameDTO
{
    public function __construct(public readonly string $name) {}
}

// Use DTOs for meaningful data groupings
class UserContactDTO
{
    public function __construct(
        public readonly string $email,
        public readonly ?string $phone,
        public readonly ?AddressDTO $address,
    ) {}
}

 

Conclusion

Data Transfer Objects are a powerful pattern that can significantly improve your Laravel applications by providing Type safety, code clarity, consistency, maintainability, and API stability. The key to using DTos effectively is finding the right balance. Not every array needs to become a DTO, but complex data structures, API boundaries, and service layer interfaces are excellent candidates for the DTO pattern.

By following the patterns and practices outlined in this guide, you'll be able to create more robust, maintainable, and professional Laravel applications that handle data transfer with confidence and clarity.

Remember: DTOs are tools for better software design, not goals in themselves. Use them where they add value, and don't be afraid to start simple and evolve our implementations as your application grows in complexity.

Happy Coding !!