Каждый слой имеет свою зону ответственности. В WordPress эти слои тоже существуют, но неявно — всё смешано в одном файле. В Laravel их принято разделять чётко.
Схема прохождения запроса
HTTP Request
↓
[Controller] — принимает запрос, вызывает сервис
↓
[FormRequest] — валидация входных данных
↓
[DTO] — упаковка данных в типизированный объект
↓
[Service] — бизнес-логика
↓
[Repository/Model] — работа с БД
↓
[Resource/DTO] — формирование ответа
↓
HTTP Response
Зачем разделять на слои?
Controller — не должен содержать логику. Его задача: принять запрос, передать данные в сервис, вернуть ответ. Если в контроллере больше 10–15 строк — что-то пошло не так.
FormRequest — отдельный класс для валидации. Вместо того чтобы писать $request->validate([...]) прямо в контроллере, выносим правила в отдельный файл. Контроллер становится чище, правила — переиспользуемыми.
DTO — Data Transfer Object. Простой объект, который переносит данные между слоями. Вместо сырого массива сервис получает типизированный объект с автокомплитом в IDE.
Service — здесь вся бизнес-логика. Расчёт цены, проверка условий, отправка уведомлений. Сервис не знает ничего про HTTP — он работает с данными, которые ему передали.
Repository / Model — работа с базой данных. Eloquent-модель уже сама по себе выполняет роль простого репозитория. Отдельный класс Repository нужен, когда запросы к БД становятся сложными и их хочется вынести из сервиса.
Resource — формирование ответа. API Resource трансформирует модель в JSON нужной структуры, отдавая клиенту только нужные поля в нужном формате.
Реальный пример: создание заказа
Пользователь отправляет POST-запрос на создание заказа. Запрос проходит через все слои.
1. Route — точка входа
// routes/api.php
use App\Http\Controllers\OrderController;
Route::post('/orders', [OrderController::class, 'store']);
2. FormRequest — валидация
Создаётся командой php artisan make:request StoreOrderRequest. Laravel автоматически применит валидацию до того, как запрос попадёт в контроллер. Если валидация не пройдёт — вернётся 422 с ошибками.
// app/Http/Requests/StoreOrderRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'shipping_address' => ['required', 'string', 'max:500'],
];
}
}
3. DTO — упаковка входных данных
Вместо того чтобы передавать в сервис сырой массив из реквеста, упаковываем данные в типизированный объект. Сервис получает предсказуемую структуру с автокомплитом в IDE — $dto->shippingAddress вместо $data['shipping_address'], где легко опечататься в ключе.
// app/DTO/OrderItemDTO.php
namespace App\DTO;
class OrderItemDTO
{
public function __construct(
public readonly int $productId,
public readonly int $quantity,
) {}
}
// app/DTO/CreateOrderDTO.php
namespace App\DTO;
use App\Http\Requests\StoreOrderRequest;
class CreateOrderDTO
{
/**
* @param OrderItemDTO[] $items
*/
public function __construct(
public readonly int $userId,
public readonly array $items,
public readonly string $shippingAddress,
) {}
/**
* Фабричный метод — создаёт DTO прямо из FormRequest.
* Контроллеру остаётся вызвать одну строку.
*/
public static function fromRequest(StoreOrderRequest $request): self
{
$items = array_map(
fn (array $item) => new OrderItemDTO(
productId: $item['product_id'],
quantity: $item['quantity'],
),
$request->validated('items')
);
return new self(
userId: $request->user()->id,
items: $items,
shippingAddress: $request->validated('shipping_address'),
);
}
}
Свойства помечены как readonly — после создания DTO его нельзя изменить. Это гарантирует, что данные не мутируют по дороге между слоями.
4. Controller — принимает и делегирует
Контроллер максимально тонкий. Он создаёт DTO из реквеста, передаёт в сервис и возвращает ответ. Ни расчётов, ни работы с БД.
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\DTO\CreateOrderDTO;
use App\Http\Requests\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use App\Services\OrderService;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function __construct(
private OrderService $orderService,
) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$dto = CreateOrderDTO::fromRequest($request);
$order = $this->orderService->create($dto);
return (new OrderResource($order))
->response()
->setStatusCode(201);
}
}
5. Service — бизнес-логика
Сервис — сердце приложения. Он принимает DTO, поэтому точно знает, какие поля доступны: $dto->items, $dto->shippingAddress. Внутри каждого item — тоже типизированный объект: $item->productId, $item->quantity. Всё обёрнуто в транзакцию — если что-то упадёт, все изменения откатятся.
// app/Services/OrderService.php
namespace App\Services;
use App\DTO\CreateOrderDTO;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use App\Notifications\OrderCreatedNotification;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function create(CreateOrderDTO $dto): Order
{
return DB::transaction(function () use ($dto) {
$user = User::findOrFail($dto->userId);
// Получаем продукты из БД одним запросом
$productIds = array_map(
fn ($item) => $item->productId,
$dto->items
);
$products = Product::whereIn('id', $productIds)
->get()
->keyBy('id');
// Считаем общую сумму
$totalPrice = 0;
foreach ($dto->items as $item) {
$product = $products[$item->productId];
$totalPrice += $product->price * $item->quantity;
}
// Создаём заказ
$order = Order::create([
'user_id' => $dto->userId,
'total_price' => $totalPrice,
'shipping_address' => $dto->shippingAddress,
'status' => 'pending',
]);
// Привязываем товары к заказу
foreach ($dto->items as $item) {
$product = $products[$item->productId];
$order->orderItems()->create([
'product_id' => $product->id,
'quantity' => $item->quantity,
'unit_price' => $product->price,
]);
}
// Отправляем уведомление
$user->notify(new OrderCreatedNotification($order));
return $order->load('orderItems');
});
}
}
6. Model — работа с БД
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'user_id',
'total_price',
'shipping_address',
'status',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
// app/Models/OrderItem.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
protected $fillable = [
'order_id',
'product_id',
'quantity',
'unit_price',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}
7. Resource — формирование ответа
Resource определяет, что именно отдаётся клиенту. Модель может иметь 20 полей, но в API мы отдаём только нужные, в нужном формате.
// app/Http/Resources/OrderResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status,
'total_price' => number_format($this->total_price, 2),
'shipping_address' => $this->shipping_address,
'items' => $this->orderItems->map(fn ($item) => [
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_price' => number_format($item->unit_price, 2),
]),
'created_at' => $this->created_at->toISOString(),
];
}
}
Итоговый JSON-ответ (201 Created)
{
"data": {
"id": 1,
"status": "pending",
"total_price": "259.98",
"shipping_address": "вул. Хрещатик, 1, Київ",
"items": [
{
"product_id": 5,
"quantity": 2,
"unit_price": "129.99"
}
],
"created_at": "2026-04-02T14:30:00.000000Z"
}
}
Структура файлов
app/
├── DTO/
│ ├── CreateOrderDTO.php ← упаковка входных данных
│ └── OrderItemDTO.php ← типизированный item
├── Http/
│ ├── Controllers/
│ │ └── OrderController.php ← тонкий, только делегирует
│ ├── Requests/
│ │ └── StoreOrderRequest.php ← валидация отдельно
│ └── Resources/
│ └── OrderResource.php ← формат ответа
├── Models/
│ ├── Order.php ← связи и fillable
│ └── OrderItem.php
├── Services/
│ └── OrderService.php ← вся бизнес-логика
└── Notifications/
└── OrderCreatedNotification.php
Когда нужен DTO, а когда нет
DTO добавляет отдельный класс, поэтому для простых CRUD-операций с 2–3 полями это может быть избыточно — можно передать данные напрямую из реквеста. Но как только у метода сервиса появляется 4+ параметра, или один и тот же набор данных передаётся в нескольких местах, или сервис вызывается не только из контроллера (например, из Artisan-команды или Job) — DTO начинает экономить время и защищать от ошибок.
По сути DTO — это просто класс с публичными свойствами и конструктором. Ни методов, ни логики. Его единственная задача — переносить данные из точки А в точку Б в предсказуемом виде.
Вот самый простой случай — регистрация пользователя:
// app/DTO/RegisterUserDTO.php
namespace App\DTO;
class RegisterUserDTO
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly string $password,
) {}
}
Без DTO контроллер передаёт в сервис сырой массив:
// Без DTO — передаём массив, легко опечататься в ключе
$service->register($request->validated());
// Внутри сервиса:
$data['emal']; // опечатка, но PHP не ругнётся — просто null
С DTO:
// С DTO — типизированный объект
$dto = new RegisterUserDTO(
name: $request->validated('name'),
email: $request->validated('email'),
password: $request->validated('password'),
);
$service->register($dto);
// Внутри сервиса:
$dto->emal; // IDE сразу подчеркнёт ошибку, PHP выбросит Error
$dto->email; // автокомплит, всё работает
Обычно так и создают вручную DTO в контроллере?
Можно и так, но чаще выносят создание в сам DTO через фабричный метод fromRequest().
class RegisterUserDTO
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly string $password,
) {}
public static function fromRequest(RegisterRequest $request): self
{
return new self(
name: $request->validated('name'),
email: $request->validated('email'),
password: $request->validated('password'),
);
}
}
Тогда в контроллере — одна строка:
$dto = RegisterUserDTO::fromRequest($request);
$service->register($dto);
Смысл в том, что логика «как собрать DTO из реквеста» живёт внутри самого DTO, а не размазана по контроллеру. Если полей станет 10 — контроллер всё равно останется чистым.
Оба варианта рабочие. Для 2–3 полей new RegisterUserDTO(...) прямо в контроллере — нормально. Когда полей больше или DTO используется в нескольких местах — fromRequest() удобнее.