Проблема DTO не относится только к Laravel. Она появляется почти в любом PHP-проекте: на Laravel, Symfony, Yii, Slim, самописном framework-free приложении или в обычном SDK.
В любом фреймворке есть один и тот же сценарий: приложение получает входные данные, валидирует их, превращает в массив и передаёт дальше по коду.
На маленьких задачах это работает нормально.
Проблемы начинаются позже:
- в массиве непонятно, какие ключи доступны;
- типы данных видны только в голове разработчика;
- IDE плохо помогает с автодополнением;
- PHPStan/Psalm не всегда могут проверить структуру массива;
- один и тот же набор данных начинает передаваться между контроллерами, сервисами, действиями и обработчиками;
- через несколько месяцев команда уже не уверена, что именно лежит внутри
$validated,$payload,$dataили$params.
В этой статье разберём, зачем нужны DTO в PHP, чем они отличаются от моделей и Value Object, как собрать DTO из входных данных, как обращаться к данным внутри DTO и когда этот подход действительно оправдан.
Примеры будут на Laravel, потому что он хорошо показывает типичную проблему с Request, validated() и передачей массивов дальше в приложение. Но сам подход не зависит от Laravel.
- Коротко
- Зачем вообще нужны DTO
- Проблема массивов в PHP-приложениях
- Что такое DTO простыми словами
- Полный пример DTO без привязки к Laravel Request
- Откуда тогда брать DTO
- FormRequest
- Mapper из request в DTO
- Контроллер
- Action / сервис приложения
- Как обращаться к данным из DTO
- Какую структуру данных мы получаем
- DTO и типизация PHP
- DTO и статический анализ
- Где должен жить DTO
- DTO не является моделью фреймворка
- DTO и Value Object — не одно и то же
- Где должна быть фабрика или mapper
- Какой вариант выбрать
- DTO без PHP 8.1
- DTO через пакеты и библиотеки
- Когда DTO полезны
- Когда DTO будут лишними
- Частые ошибки при использовании DTO
- 1. Делать DTO изменяемым
- 2. Наследовать DTO от ORM-модели
- 3. Класть фабричную логику в DTO
- 4. Класть бизнес-логику в DTO
- 5. Создавать DTO ради каждого массива
- 6. Смешивать DTO, Resource и Model
- Практический чек-лист
- Итог
- Что читать дальше
Коротко
DTO — это объект передачи данных.
Он помогает:
- заменить неявные массивы на явную структуру;
- зафиксировать список полей;
- указать типы данных;
- упростить автодополнение в IDE;
- снизить количество ошибок из-за неправильных ключей массива;
- отделить входные данные от бизнес-логики;
- сделать код понятнее для команды.
Пример вместо массива:
$validated = $request->validated();
$customer->name = $validated['name'];
$customer->email = $validated['email'];
используем DTO, созданный через mapper:
$customerData = $storeCustomerDataMapper->fromRequest(
request: $request,
);
$customer->name = $customerData->name;
$customer->email = $customerData->email;
Смысл не в том, чтобы написать больше классов. Смысл в том, чтобы данные стали явными.
Зачем вообще нужны DTO
Большая часть web-приложений работает с данными:
- получает данные из HTTP-запроса;
- валидирует их;
- преобразует типы;
- передаёт в сервисы;
- сохраняет в базу;
- отправляет в очереди;
- возвращает наружу через API.
Во фреймворках это часто выглядит одинаково: входные данные превращаются в массив и начинают гулять по приложению.
В Laravel это обычно начинается так:
public function store(CustomerRequest $request): RedirectResponse
{
$validated = $request->validated();
Customer::query()->create([
'name' => $validated['name'],
'email' => $validated['email'],
'birth_date' => $validated['birth_date'],
]);
return redirect()->route('customers.index');
}
Этот код понятен, пока он находится в одном методе.
Но если данные передаются дальше:
$this->customerService->create($validated);
возникает вопрос: что именно лежит в $validated?
- Есть ли там
name? - Есть ли
email? birth_date— строка илиCarbon?- Может ли поле быть
null? - Есть ли там лишние поля?
- Кто отвечает за преобразование даты?
Массив на эти вопросы не отвечает.
DTO отвечает.
Проблема массивов в PHP-приложениях
Рассмотрим обычный пример на Laravel.
Есть FormRequest:
use Illuminate\Foundation\Http\FormRequest;
class StoreCustomerRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'birth_date' => ['required', 'date'],
];
}
}
И есть контроллер:
use Illuminate\Http\RedirectResponse;
class CustomerController
{
public function store(StoreCustomerRequest $request): RedirectResponse
{
$validated = $request->validated();
Customer::query()->create([
'name' => $validated['name'],
'email' => $validated['email'],
'birth_date' => $validated['birth_date'],
]);
return redirect()->route('customers.index');
}
}
Формально всё нормально.
Но $validated — это массив. Для PHP и IDE это просто набор ключей и значений.
Проблемы:
- можно ошибиться в ключе:
$validated['emial']; - можно забыть преобразовать дату;
- можно передать массив в сервис, где уже не видно правил валидации;
- можно начать использовать данные в нескольких местах и получить дублирование;
- можно добавить поле в request и забыть обработать его в сервисе.
Массив удобен как временная структура. Но как контракт между слоями приложения он слабый.
Что такое DTO простыми словами
DTO — это простой объект, который описывает набор данных.
Например:
use Illuminate\Support\Carbon;
class StoreCustomerData
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly Carbon $birthDate,
) {
}
}
Теперь структура данных видна сразу:
name— строка;email— строка;birthDate— объектCarbon;- все свойства доступны только для чтения;
- объект нельзя случайно изменить после создания.
Использование:
$customerData = new StoreCustomerData(
name: 'Иван',
email: 'ivan@example.com',
birthDate: Carbon::parse('1990-01-01'),
);
$name = $customerData->name;
$email = $customerData->email;
$birthDate = $customerData->birthDate;
Такой объект не делает магии. Он просто превращает неявный массив в явную структуру.
Полный пример DTO без привязки к Laravel Request
Правильнее держать DTO простой структурой. Он не должен знать, откуда пришли данные: из Laravel Request, Symfony Request, CLI-команды, webhook, очереди или внешнего API.
DTO:
class StoreCustomerData
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly Carbon $birthDate,
public readonly ?string $phone,
) {
}
}
В этом классе нет:
fromRequest();- фабричных методов;
- зависимости от Laravel;
- валидации HTTP-запроса;
- бизнес-логики;
- обращения к базе данных.
Это просто объект с данными.
Откуда тогда брать DTO
Создание DTO — отдельная задача. Её можно решить mapper/factory-классом на границе приложения.
В Laravel это может выглядеть так.
FormRequest
class StoreCustomerRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'birth_date' => ['required', 'date'],
'phone' => ['nullable', 'string', 'max:32'],
];
}
}
Mapper из request в DTO
class StoreCustomerDataMapper
{
public function fromRequest(StoreCustomerRequest $request): StoreCustomerData
{
$validated = $request->validated();
return new StoreCustomerData(
name: (string) $validated['name'],
email: (string) $validated['email'],
birthDate: Carbon::parse($validated['birth_date']),
phone: array_key_exists('phone', $validated)
? $validated['phone']
: null,
);
}
}
Зависимость от Laravel находится здесь, а не внутри DTO. Это важная граница.
Контроллер
class CustomerController
{
public function __construct(
private readonly StoreCustomerDataMapper $storeCustomerDataMapper,
private readonly CreateCustomerAction $createCustomerAction,
) {
}
public function store(StoreCustomerRequest $request): RedirectResponse
{
$customerData = $this->storeCustomerDataMapper->fromRequest(
request: $request,
);
$this->createCustomerAction->handle(
customerData: $customerData,
);
return redirect()->route('customers.index');
}
}
Action / сервис приложения
class CreateCustomerAction
{
public function handle(StoreCustomerData $customerData): Customer
{
return Customer::query()->create([
'name' => $customerData->name,
'email' => $customerData->email,
'birth_date' => $customerData->birthDate->toDateString(),
'phone' => $customerData->phone,
]);
}
}
Теперь данные проходят понятный путь:
StoreCustomerRequestвалидирует входной HTTP-запрос.StoreCustomerDataMapperпревращает валидированный массив в DTO.StoreCustomerDataхранит данные и не знает про Laravel.CustomerControllerпередаёт DTO в сценарий приложения.CreateCustomerActionработает с явной структурой данных.- Eloquent сохраняет данные в базу.
Как обращаться к данным из DTO
DTO — это обычный объект.
Обращение к данным:
$customerData->name;
$customerData->email;
$customerData->birthDate;
$customerData->phone;
В отличие от массива:
$validated['birth_date'];
у объекта есть преимущества:
- IDE подсказывает доступные свойства;
- свойства имеют типы;
- PHPStan/Psalm лучше понимают структуру;
- легче переименовывать поля;
- меньше риска ошибиться в ключе массива.
Например, если написать:
$customerData->birth_date;
IDE и статический анализатор сразу подскажут, что такого свойства нет.
Какую структуру данных мы получаем
После создания DTO мы получаем объект такого вида:
StoreCustomerData {
name: 'Иван Иванов',
email: 'ivan@example.com',
birthDate: Carbon,
phone: '+79990000000'|null,
}
Важная разница:
birth_dateиз HTTP-запроса приходит строкой;birthDateвнутри DTO уже является объектом даты;- nullable-поле
phoneявно описано как?string; - остальные свойства обязательны.
DTO становится границей между внешним миром и приложением.
Внешний мир присылает строки и массивы. Приложение работает с типами.
DTO и типизация PHP
PHP — язык с динамической и слабой типизацией.
Это значит:
- данные из HTTP-запроса чаще всего приходят строками;
- типы проверяются во время выполнения;
- переменная может получить значение другого типа;
- строгая типизация
declare(strict_types=1)помогает, но не превращает PHP в Rust или Go.
Пример:
$id = '1';
function find(int $id): Model
{
// ...
}
find($id);
Без строгой типизации PHP может привести строку '1' к числу.
Со строгой типизацией:
declare(strict_types=1);
find('1');
будет ошибка TypeError.
Но даже строгая типизация не решает проблему структуры массива:
$validated = $request->validated();
Для PHP это всё равно массив.
DTO помогает зафиксировать структуру данных на уровне объекта.
DTO и статический анализ
DTO особенно полезны вместе с инструментами статического анализа:
- PHPStan;
- Psalm;
- Phan;
- PhpStorm inspections.
Массив:
/** @var array $validated */
$validated = $request->validated();
мало что сообщает анализатору.
DTO:
public function handle(StoreCustomerData $customerData): Customer
сразу сообщает:
- какой объект передан;
- какие свойства доступны;
- какие типы у свойств;
- какие поля nullable;
- где сломается код при изменении структуры.
Это снижает когнитивную нагрузку.
Разработчику не нужно каждый раз лезть в FormRequest, дампить массив или вспоминать, какие ключи там были.
Где должен жить DTO
В простом PHP-проекте DTO можно положить, например, сюда:
src/
└── Data/
└── StoreCustomerData.php
В Laravel-проекте структура может быть такой:
app/
└── Data/
└── StoreCustomerData.php
В модульной структуре:
app/
└── Modules/
└── Customer/
├── Data/
│ └── StoreCustomerData.php
├── Mappers/
│ └── StoreCustomerDataMapper.php
├── Http/
│ └── Requests/
│ └── StoreCustomerRequest.php
└── Actions/
└── CreateCustomerAction.php
В DDD-подходе лучше быть аккуратнее: DTO не должен сам создаваться из FormRequest, потому что тогда он начинает знать о Laravel HTTP-слое.
Практическое правило:
- DTO хранит данные и остаётся обычным PHP-объектом;
- Mapper/Factory создаёт DTO из внешних данных;
- HTTP Request валидирует входной запрос;
- Value Object с бизнес-смыслом живёт ближе к domain layer;
- ORM Model / Entity относится к persistence layer или доменной модели, в зависимости от архитектуры проекта;
- API Resource / Response DTO отвечает за формат ответа наружу.
DTO не является моделью фреймворка
DTO и модель фреймворка решают разные задачи.
В Laravel это особенно хорошо видно на примере Eloquent.
Eloquent-модель:
- знает про таблицу базы данных;
- умеет сохраняться;
- имеет relations;
- имеет casts;
- работает с query builder;
- является частью persistence layer.
DTO:
- не сохраняется в базу сам;
- не имеет relations;
- не делает запросы;
- не содержит ORM-логику;
- просто переносит данные между слоями.
Плохо:
class StoreCustomerData extends Model
{
}
DTO не должен наследоваться от Eloquent Model, Doctrine Entity, ActiveRecord-модели или любой другой persistence-модели.
Нормально:
class StoreCustomerData
{
public function __construct(
public readonly string $name,
public readonly string $email,
) {
}
}
DTO и Value Object — не одно и то же
DTO часто путают с Value Object.
Разница:
| Понятие | Для чего нужно |
|---|---|
| DTO | Передать набор данных между слоями |
| Value Object | Выразить доменное значение и его правила |
Пример DTO:
class StoreCustomerData
{
public function __construct(
public readonly string $email,
) {
}
}
Пример Value Object:
class Email
{
public function __construct(
public readonly string $value,
) {
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email.');
}
}
}
DTO может содержать Value Object:
class StoreCustomerData
{
public function __construct(
public readonly string $name,
public readonly Email $email,
) {
}
}
Но это уже более строгий подход. Для обычного CRUD часто достаточно простых скалярных типов и объекта даты.
Где должна быть фабрика или mapper
DTO лучше держать примитивным объектом. Поэтому создание DTO из внешних данных лучше выносить в отдельный класс.
Плохой вариант для строгой архитектуры:
class StoreCustomerData
{
public function __construct(
public readonly string $name,
public readonly string $email,
) {
}
public static function fromRequest(StoreCustomerRequest $request): self
{
// DTO знает про Laravel Request.
}
}
Проблема:
- DTO зависит от Laravel;
- DTO знает про HTTP-слой;
- простой объект данных получает дополнительную ответственность;
- такой DTO сложнее переиспользовать в CLI, очереди, тестах или другом фреймворке.
Лучше:
class StoreCustomerData
{
public function __construct(
public readonly string $name,
public readonly string $email,
) {
}
}
И отдельно:
class StoreCustomerDataMapper
{
public function fromArray(array $data): StoreCustomerData
{
return new StoreCustomerData(
name: (string) $data['name'],
email: (string) $data['email'],
);
}
}
Для Laravel можно сделать адаптерный метод:
class StoreCustomerDataMapper
{
public function fromRequest(StoreCustomerRequest $request): StoreCustomerData
{
return $this->fromArray(
data: $request->validated(),
);
}
public function fromArray(array $data): StoreCustomerData
{
return new StoreCustomerData(
name: (string) $data['name'],
email: (string) $data['email'],
);
}
}
Так зависимость от Laravel остаётся в mapper, а не в DTO.
Какой вариант выбрать
Для статьи и для аккуратной архитектуры лучше использовать один основной вариант:
- DTO — только структура данных;
- mapping — отдельный mapper/factory;
- Laravel Request остаётся за пределами DTO;
- бизнес-логика остаётся за пределами DTO.
В этой статье используем один последовательный вариант: DTO остаётся простой структурой данных, а преобразование входных данных выполняет отдельный mapper. Так текст не противоречит сам себе: если DTO объявлен примитивным объектом, он не должен одновременно быть фабрикой для самого себя.
Главное — не доводить архитектуру до абсурда.
Если в проекте три формы и пять таблиц, DTO-фабрики на каждый чих могут быть лишними.
Если проект живёт годами, в нём много разработчиков, сложные формы, интеграции и очереди — DTO быстро окупаются.
DTO без PHP 8.1
Если проект старый и в нём нет readonly, можно писать так:
use Illuminate\Support\Carbon;
class StoreCustomerData
{
public string $name;
public string $email;
public Carbon $birthDate;
public ?string $phone;
public function __construct(
string $name,
string $email,
Carbon $birthDate,
?string $phone,
) {
$this->name = $name;
$this->email = $email;
$this->birthDate = $birthDate;
$this->phone = $phone;
}
}
Если проект совсем старый и типизированные свойства недоступны, остаются PHPDoc и статический анализ:
class StoreCustomerData
{
/** @var string */
public $name;
/** @var string */
public $email;
/** @var \Illuminate\Support\Carbon */
public $birthDate;
/** @var string|null */
public $phone;
}
Это запасной вариант.
В современных проектах лучше использовать нативные типы PHP.
DTO через пакеты и библиотеки
В PHP и Laravel-сообществе часто используют готовые библиотеки для DTO.
Например:
spatie/data-transfer-object— старый подход к DTO;spatie/laravel-data— более современный вариант для Laravel-приложений, но он уже добавляет framework-специфику;- Symfony Serializer + собственные DTO;
- самописные immutable DTO без внешних пакетов.
Пример с spatie/laravel-data:
use Spatie\LaravelData\Data;
class StoreCustomerData extends Data
{
public function __construct(
public string $name,
public string $email,
public string $birthDate,
public ?string $phone,
) {
}
}
Такой пакет может дать:
- автоматическое создание объекта данных из request;
- валидацию;
- transform;
- работу с collections;
- сериализацию для API.
Но начинать лучше не с пакета, а с понимания идеи.
DTO — это не про конкретную библиотеку. Это про явный контракт данных.
Когда DTO полезны
DTO особенно полезны, если:
- проект большой;
- над проектом работает команда;
- есть сложные формы;
- данные передаются между слоями;
- есть application services/actions;
- есть очереди;
- есть интеграции с внешними API;
- используется PHPStan или Psalm;
- важно уменьшить количество неявных массивов;
- нужно сделать код понятнее через полгода.
Примеры хороших кандидатов для DTO:
- создание клиента;
- оформление заказа;
- создание платежа;
- изменение профиля;
- импорт данных;
- параметры поиска;
- входящие webhook payload;
- команды для очередей;
- данные для внешнего API.
Когда DTO будут лишними
DTO не нужны везде.
Они могут быть избыточны, если:
- это маленький CRUD;
- данные не выходят за пределы одного метода;
- форма содержит 1–2 поля;
- проект небольшой;
- команда не использует статический анализ;
- DTO просто дублирует ORM-модель без пользы;
- ради одного поля создаётся пять классов.
Плохой пример:
class UpdateNameData
{
public function __construct(
public readonly string $name,
) {
}
}
Если это единственная операция в маленьком проекте, DTO может быть лишним.
Архитектура должна снижать сложность, а не торжественно оформлять каждую переменную в отдельный класс.
Частые ошибки при использовании DTO
1. Делать DTO изменяемым
Плохо:
$customerData->name = 'Новое имя';
Лучше делать DTO immutable через public readonly.
2. Наследовать DTO от ORM-модели
DTO не должен быть моделью базы данных.
3. Класть фабричную логику в DTO
DTO должен оставаться простой структурой. Если он начинает знать про Request, Response, ORM, контейнер или фреймворк, он перестаёт быть чистым объектом передачи данных.
Плохо:
StoreCustomerData::fromRequest($request);
Лучше:
$customerData = $storeCustomerDataMapper->fromRequest(
request: $request,
);
4. Класть бизнес-логику в DTO
DTO должен хранить данные и не должен принимать бизнес-решения.
Плохо:
$customerData->canReceiveDiscount();
Лучше вынести бизнес-логику в доменную модель, сервис или action.
5. Создавать DTO ради каждого массива
DTO нужен там, где структура данных действительно важна.
6. Смешивать DTO, Resource и Model
Разные задачи:
- DTO — входные/передаваемые данные;
- ORM Model / Entity — база данных;
- API Resource / Response — формат ответа наружу;
- Value Object — доменное значение.
Практический чек-лист
DTO стоит добавить, если:
- массив передаётся дальше контроллера;
- структура массива используется в нескольких местах;
- нужно преобразовать типы;
- есть nullable/default-поля;
- IDE не помогает понять доступные ключи;
- PHPStan/Psalm не может нормально проверить код;
- с этим набором данных работает несколько разработчиков;
- вы уже ловили ошибки из-за неправильного ключа массива.
DTO лучше не добавлять, если:
- данные используются один раз;
- код остаётся проще без DTO;
- DTO полностью дублирует ORM-модель;
- нет реальной проблемы с читаемостью или безопасностью данных.
Итог
DTO в PHP — это способ сделать данные явными.
Они помогают перейти от неструктурированных массивов:
$validated['birth_date'];
к понятным объектам:
$customerData->birthDate;
Это даёт:
- понятную структуру данных;
- автодополнение в IDE;
- лучшую поддержку PHPStan/Psalm;
- меньше ошибок в ключах массива;
- явную границу между внешними входными данными и application layer;
- более читаемый код для команды.
Главная мысль:
DTO нужен не потому, что массивы плохие. DTO нужен тогда, когда массив перестаёт быть понятным контрактом.
В маленьком CRUD DTO может быть лишним. В большом PHP-проекте с командой, сервисами, очередями, интеграциями и долгой жизнью кодовой базы DTO быстро становится способом сохранить порядок.
Много не понятного. Куски кода оторваны от классов. Это статья для сеньоров которые уже знают DTO? Или это статья для людей которые знают смежные языки и структуры данных в них и поэтому могут понять – почему в ПХП нет того или иного?
Но если эта статья хотя бы для мидлов (про джунов молчу), то желательно на конкретных примерах рассказывать и показывать что за структура данных получается, как к ней обращаться из клиента, опубликовать полный класс DTO
У меня опыт разработки 1,5 года и я не считаю себя мидлом, но я понял о чём говорится в статье.
дак давайте поработаем с вами и за пол года на мидл + прыгнем