Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 068 / 100

Data Transfer Objects in PHP in PHP & Laravel Development

Typed data instead of arrays.

Expona Admin6 min read

Why move beyond arrays?

Working with untyped arrays in PHP applications creates friction. You pass data around, hoping the next developer—or you, six months later—remembers what keys exist and what types they hold. When a controller receives a request, it validates data and then passes an array to a service. The service passes another array to the repository. Nobody knows exactly what shape that data has. This is where Data Transfer Objects in PHP in PHP & Laravel Development changes the game. DTOs give you typed, validated, self-documenting objects that make your code clearer and catch bugs earlier.

Readonly classes in PHP 8.2+

PHP 8.2 introduced readonly classes, a perfect fit for DTOs. A readonly class prevents mutation after instantiation—exactly what you want for data passing through your layers.

php
readonly class CreateUserDTO
{
    public function __construct(
        public string $name,
        public string $email,
        public int $age,
    ) {}
}

When you construct this object, the properties are locked. Try to modify them later and PHP throws an error. This immutability reduces bugs and makes intent crystal clear: this object exists to carry data, not to be a mutable entity.

You can also add methods to DTOs for convenience:

php
readonly class CreateUserDTO
{
    public function __construct(
        public string $name,
        public string $email,
        public int $age,
    ) {}

    public function getFullDisplayName(): string
    {
        return strtoupper($this->name);
    }
}

Keep methods lightweight—transformation and formatting only. DTOs are not for business logic. If you're tempted to add complex logic, that belongs in a service or domain object instead.

spatie/laravel-data for Laravel

While readonly classes work, the Spatie team built spatie/laravel-data to handle DTOs in Laravel with less boilerplate and better integration. Install it with:

php
composer require spatie/laravel-data

Here's how you define a DTO with Spatie's package:

php
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Min;

class CreateUserData extends Data
{
    public function __construct(
        public string $name,
        #[Email]
        public string $email,
        #[Min(18)]
        public int $age,
    ) {}
}

Spatie's Data class is already immutable by default. It also offers automatic casting, attribute-based validation, and easy transformation to and from arrays or JSON. When you need to instantiate it from request data:

php
class UserController extends Controller
{
    public function store(Request $request)
    {
        $userData = CreateUserData::from($request->validated());
        
        $user = $this->userService->createUser($userData);
        
        return response()->json($user, 201);
    }
}

The from() method accepts an array and maps it to your DTO properties, handling type coercion and validation attributes. If data doesn't match the DTO's rules, it throws early, before hitting your business logic.

Validate on creation

One core benefit of DTOs is validation at the boundary. In a typical Laravel app, you validate in the controller using Request validation rules. With DTOs, you move validation closer to the data structure itself.

Using Spatie's attributes:

php
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\StringType;

class UpdateProfileData extends Data
{
    public function __construct(
        #[StringType, Max(100)]
        public string $name,
        
        #[Email]
        public string $email,
        
        #[Min(18), Max(120)]
        public int $age,
    ) {}
}

When you try to instantiate this DTO with invalid data, validation runs automatically. This prevents invalid data from ever reaching your services or repositories. You can also use Laravel's Form Request validation in combination:

php
class UpdateProfileRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->user());
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'email' => ['required', 'email', 'unique:users'],
            'age' => ['required', 'integer', 'min:18', 'max:120'],
        ];
    }
}

class UserController extends Controller
{
    public function update(UpdateProfileRequest $request)
    {
        $data = UpdateProfileData::from($request->validated());
        
        $this->userService->updateProfile($request->user(), $data);
        
        return response()->json(['success' => true]);
    }
}

By the time your DTO reaches a service, you know every property is valid and correctly typed. No more defensive null checks or type casting inside business logic.

Pass DTOs between layers

DTOs shine when passing data between architectural layers. Instead of untyped arrays or entity models leaking into your application layer, you use DTOs as contracts.

Consider a typical flow:

php
// Controller receives and validates
class OrderController extends Controller
{
    public function checkout(CheckoutRequest $request)
    {
        $checkoutData = CheckoutData::from($request->validated());
        
        $order = app(OrderService::class)->process($checkoutData);
        
        return response()->json($order);
    }
}

// Service uses DTO and calls repository
class OrderService
{
    public function __construct(private OrderRepository $repository) {}

    public function process(CheckoutData $data): Order
    {
        // Validate business rules
        if ($data->total <= 0) {
            throw new InvalidCheckoutException('Total must be positive');
        }

        // Create order entity
        $order = new Order(
            customer_id: $data->customer_id,
            total: $data->total,
            items: $data->items,
        );

        return $this->repository->save($order);
    }
}

// Repository accepts DTO for creation
class OrderRepository
{
    public function save(Order $order): Order
    {
        // Persist to database
        $order->save();
        return $order;
    }
}

Each layer knows exactly what to expect. Controllers know services need CheckoutData. Services know repositories work with Order entities. This clarity scales as your application grows.

You can also use DTOs for responses, keeping internal models private:

php
class UserListData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
    ) {}
}

// In controller
public function index()
{
    $users = User::all();
    return UserListData::collection($users);
}

Spatie's collection() method transforms a collection of models into a collection of DTOs, perfect for API responses.

Common mistakes

Putting business logic in DTOs. DTOs carry data. Don't add methods like calculateTax() or checkInventory(). That belongs in services or domain objects.

Over-nesting DTOs. It's tempting to create a DTO that contains another DTO that contains another. This gets complex fast. Keep structures flat where possible, and use nested DTOs only when it genuinely reflects your domain.

Creating one DTO per entity. You don't need a DTO for every model. Use DTOs at boundaries—requests, responses, inter-service communication. Internal domain objects can stay as models or value objects.

Forgetting to validate. DTOs aren't validation replacements if you skip validation attributes. Use Form Request validation in Laravel, then DTO attributes to reinforce it. Belt and suspenders.

Key takeaways

  • DTOs replace untyped arrays with strongly typed, self-documenting objects.
  • PHP 8.2+ readonly classes prevent mutation, making DTOs immutable by design.
  • Spatie's laravel-data package reduces boilerplate and integrates validation attributes seamlessly.
  • Validate data on DTO creation to prevent invalid data from reaching your business logic.
  • Pass DTOs between layers (controller → service → repository) to clarify contracts and reduce coupling.
  • Keep DTOs simple: use them for data transfer and light transformations, not business logic.
  • Combine Form Request validation with DTO attributes for defense in depth.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles