Разделение DTO (Data Transfer Object) и ORM-модели — это архитектурное решение, которое защищает ваше приложение от утечки внутренней структуры базы данных, упрощает тестирование и позволяет гибко управлять тем, какие данные передаются по сети. В PHP это реализуется через явное копирование состояния сущности в отдельный объект перед отправкой клиенту.
1. Разделение ответственности: зачем DTO и модель должны жить отдельно
ORM-модель отвечает за взаимодействие с базой данных, управление транзакциями, каскадные удаления и бизнес-правила. Она должна содержать все поля таблицы, включая служебные (created_at, updated_by, deleted_at) и технические (id, uuid).
DTO отвечает только за передачу данных между слоями приложения или внешними системами. Его структура определяется потребностями API, а не схемой БД.
Если вы возвращаете ORM-модель напрямую через сериализатор (например, json_encode в Laravel или Symfony), клиент получает полную копию структуры таблицы. Это позволяет злоумышленникам узнать имена колонок, типы данных и наличие скрытых полей, что является формой утечки информации.
Разделение также упрощает тестирование: вы можете проверить логику работы с БД отдельно от того, какие данные формируются для ответа пользователю.
2. Структура данных в ORM-модели: сущность, связи и бизнес-логика
В ORM-модели (например, Eloquent в Laravel или Doctrine Entities) должны присутствовать все атрибуты таблицы, включая приватные поля.
Модель должна включать методы доступа к связанным сущностям (relations), чтобы поддерживать целостность данных при запросах.
В модель можно внедрять бизнес-логику: валидацию на уровне сущности, триггеры событий (например, запись лога после сохранения), расчеты итоговых значений.
Пример условной структуры модели User в PHP:
class User extends Model {
protected $fillable = ['name', 'email', 'password_hash', 'role_id'];
protected $hidden = ['password_hash', 'remember_token']; // Скрытые поля не должны утекать
public function posts() { return $this->hasMany(Post::class); }
}
3. Структура данных в DTO: только то, что нужно для внешнего интерфейса
DTO — это простой класс или массив, содержащий только поля, которые нужны клиенту.
В DTO не должно быть методов доступа к базе данных, транзакций или сложной бизнес-логики. Его задача — упаковка данных.
Пример условного DTO UserResponse:
class UserResponse {
public string $id;
public string $name;
public string $email;
// Поля role_id, password_hash отсутствуют
}
4. Миграция данных: как копировать состояние из модели в DTO
Существует три основных подхода к миграции данных:
Ручное копирование полей. Вы явно указываете, какие поля передать. Это самый безопасный метод, так как исключает случайную утечку служебных полей.
Использование маппинга через конструкторы или методы-сеттеры. Создаете DTO и заполняете его значениями из модели.
Пример:
$dto = new UserResponse();
$dto->id = $user->id;
$dto->name = $user->name;
$dto->email = $user->email;
Использование пакетов маппинга (например, Spatie/Laravel-Data или Mapstruct). Эти библиотеки позволяют объявить DTO и автоматически скопировать поля по имени. Однако они не фильтруют служебные поля автоматически — фильтрацию нужно настраивать вручную.
Важно: никогда не используйте toArray() или jsonSerialize() модели напрямую для формирования ответа, если вы не уверены, что все поля безопасны.
5. Валидация на уровне DTO: защита от некорректных входных данных
DTO часто используется как точка входа для валидации входящих данных (например, при создании пользователя).
В PHP это реализуется через атрибуты (Laravel Validation) или специализированные пакеты (Spatie/Laravel-Data с валидацией).
Пример:
[ValidateRequired]
[ValidateEmail]
public string $email;
Валидация должна происходить до того, как данные попадут в ORM-модель. Это защищает базу данных от записи некорректных значений.
Если DTO используется для ответа, валидация здесь проверяет целостность переданных данных (например, отсутствие null там, где ожидается строка).
6. Сериализация и десериализация: контроль над тем, что уходит в JSON
При сериализации DTO в JSON (например, через json_encode) вы получаете чистый набор данных без служебных полей модели.
Если вы используете библиотеки маппинга, убедитесь, что они настроены на игнорирование приватных полей или полей с префиксом underscore (_).
Десериализация — процесс обратный. При получении данных от клиента (POST/PUT) их нужно сначала валидировать и упаковать в DTO, а затем только после этого сохранять в модель.
Пример потока данных:
1. Клиент отправляет JSON -> 2. Валидация через DTO -> 3. Преобразование DTO в Модель (с заполнением только fillable полей) -> 4. Сохранение в БД.
7. Обработка исключений и безопасность: предотвращение утечки структуры БД
Одна из главных целей разделения — защита от инсайдерских атак или ошибок разработчиков.
Если вы случайно вернете модель вместо DTO, клиент получит поля типа deleted_at, soft_deleted_at, которые могут содержать информацию о состоянии записи.
В Laravel используйте метод toArray() с параметром $except для исключения служебных полей, но надежнее использовать DTO.
Пример безопасного ответа:
$response = new UserResponse();
$response->id = $user->id;
$response->name = $user->name;
return response()->json($response);
Если вы используете маппинг, добавьте правило игнорирования приватных полей в конфигурации библиотеки.
8. Тестирование и рефакторинг: как поддерживать чистоту кода при изменениях
При изменении структуры таблицы БД (добавление нового поля) ORM-модель обновляется автоматически, но DTO остается неизменным, если новое поле не нужно клиенту.
Это позволяет тестировать API независимо от изменений в базе данных.
Рефакторинг кода становится проще: вы можете переименовать поля в модели без изменения контракта API, изменив только маппинг.
Тестируйте сценарии утечки: попробуйте сериализовать модель и убедитесь, что служебные поля не попадают в JSON.
Вопросы и ответы
Можно ли использовать маппинг-библиотеки вместо ручного копирования?
Да, это допустимо и часто рекомендуется для упрощения кода. Однако помните, что такие библиотеки не фильтруют служебные поля автоматически — вам нужно явно указать, какие поля передавать, или настроить правила игнорирования.
Нужно ли создавать DTO для каждого запроса?
Нет. Создавайте общие DTO-классы для типов данных (например, UserResponse, PostResponse) и используйте их повторно в разных контроллерах или маршрутах.
Как проверить, что DTO не содержит служебных полей?
Используйте статический анализатор кода (например, PHPStan) или утилиту для проверки JSON-ответов. Также можно добавить тесты, которые проверяют структуру возвращаемого JSON на отсутствие ожидаемых служебных полей.
По теме
- Как ограничить расходы на облачные LLM в бизнес-приложении
- Как построить LLM Router: дешёвая модель для простых задач, сильная — для сложных
<?php
// Условная ORM-модель
class User extends Model {
protected $fillable = ['name', 'email', 'password_hash'];
protected $hidden = ['password_hash'];
}
// Условный DTO
class UserResponse {
public string $id;
public string $name;
public string $email;
}
// Миграция данных
function getUserResponse(User $user): UserResponse {
$response = new UserResponse();
$response->id = $user->id;
$response->name = $user->name;
$response->email = $user->email;
return $response;
}
// Безопасный ответ
$response = getUserResponse($user);
return response()->json($response);