Слоистая (Layered) архитектура в Laravel
Glossary overview

Слоистая (Layered) архитектура в Laravel

Каждый слой имеет свою зону ответственности. В 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() удобнее.