Разделение 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 на отсутствие ожидаемых служебных полей.

По теме

<?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);