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.
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:
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:
composer require spatie/laravel-data
Here's how you define a DTO with Spatie's package:
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:
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:
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:
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:
// 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:
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.






Comments
Loading comments…