DTO в PHP: как перестать передавать массивы и сделать данные понятными

Проблема 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 — это объект передачи данных.

Он помогает:

  • заменить неявные массивы на явную структуру;
  • зафиксировать список полей;
  • указать типы данных;
  • упростить автодополнение в 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,
        ]);
    }
}

Теперь данные проходят понятный путь:

  1. StoreCustomerRequest валидирует входной HTTP-запрос.
  2. StoreCustomerDataMapper превращает валидированный массив в DTO.
  3. StoreCustomerData хранит данные и не знает про Laravel.
  4. CustomerController передаёт DTO в сценарий приложения.
  5. CreateCustomerAction работает с явной структурой данных.
  6. 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 быстро становится способом сохранить порядок.

Что читать дальше

Рейтинг
( 10 оценок, среднее 3.9 из 5 )
Максим Гречушников/ автор статьи
Мне 40 лет, и больше половины жизни я занимаюсь разработкой. За это время я работал с корпоративными системами, highload-проектами, интеграциями, платежными решениями, внутренними платформами и legacy-кодом, который пережил не одного разработчика. Некоторые системы, кажется, переживут и нас. Моя специализация — PHP, Laravel, backend-архитектура, DDD, тестирование, рефакторинг и техническое лидерство. Мне интересны задачи, где нужно соединить код, бизнес, данные, процессы и здравый смысл. Последние годы я много работаю с LLM и AI-инструментами: использую их для проектирования, анализа кода, документации, тестов, ревью, работы с legacy и построения инженерных процессов. Для меня LLM — это не замена разработчику, а усилитель для тех, кто умеет думать системно. В блоге я пишу о Laravel, PHP, архитектуре, тестировании, инженерной практике, управлении разработкой и реальном применении AI в работе программиста.
Понравилась статья? Поделиться с друзьями:
Комментарии: 3
  1. Алекс

    Много не понятного. Куски кода оторваны от классов. Это статья для сеньоров которые уже знают DTO? Или это статья для людей которые знают смежные языки и структуры данных в них и поэтому могут понять – почему в ПХП нет того или иного?
    Но если эта статья хотя бы для мидлов (про джунов молчу), то желательно на конкретных примерах рассказывать и показывать что за структура данных получается, как к ней обращаться из клиента, опубликовать полный класс DTO

  2. Cedric

    У меня опыт разработки 1,5 года и я не считаю себя мидлом, но я понял о чём говорится в статье.

  3. Максим Гречушников (автор)

    дак давайте поработаем с вами и за пол года на мидл + прыгнем

Комментарии закрыты.