---
title: 'Action-классы, сервисы и DTO в Laravel | DevSense'
description: 'Как разгрузить толстые контроллеры и модели в Laravel: Action-классы, сервисный слой, DTO на readonly-классах, границы модулей, транзакции и события — и когда всё это оверинжиниринг.'
faq:
    - { question: 'Чем Action-класс отличается от сервиса в Laravel?', answer: 'Action описывает один бизнес-сценарий (оформить заказ, пригласить участника в команду) и имеет один публичный метод. Сервис группирует операции вокруг ресурса или интеграции (платёжный шлюз, калькулятор цен) и не знает о сценарии целиком. Action вызывает сервисы, а не наоборот.' }
    - { question: 'Зачем DTO, если есть $request->validated() и массивы?', answer: 'Массив не говорит, какие ключи в нём есть и каких они типов: опечатка в ключе всплывает только в рантайме, IDE не подсказывает, статический анализ бессилен. DTO на readonly-классе фиксирует контракт — тип каждого поля, обязательность и неизменяемость. Один и тот же Action можно вызвать из контроллера, консольной команды, задачи очереди и теста.' }
    - { question: 'Можно ли передавать Eloquent-модели между модулями?', answer: 'Внутри модуля — да. Между модулями лучше передавать DTO или идентификаторы: модель тянет за собой связи, ленивые запросы и возможность изменить чужие данные через save(). Граница из DTO делает зависимости явными и позволяет менять схему таблиц модуля, не ломая соседей.' }
    - { question: 'Когда Action-классы и DTO — это оверинжиниринг?', answer: 'Для CRUD без бизнес-правил, админок и прототипов. Если контроллер в пять строк валидирует запрос и сохраняет модель, вынос в Action и DTO добавит три файла и ни одного правила. Слои окупаются, когда у сценария появляются инварианты, несколько точек входа или побочные эффекты.' }
published: '2026-10-03'
---
# Action-классы, сервисы и DTO в Laravel: как разгрузить контроллеры и модели

Почти каждый Laravel-проект проходит одну и ту же стадию. `OrderController@store` вырастает до двухсот строк: валидация, расчёт цены с промокодом, резерв товара, списание денег, письмо клиенту, вебхук в CRM. Потом появляется API для мобильного приложения, и эти двести строк копируются во второй контроллер. Потом — консольная команда для импорта заказов из маркетплейса, третья копия. Через полгода в одной из копий исправлен баг с округлением, а в двух других — нет.

Параллельно растёт модель `Order`: в ней методы `send()`, `refund()`, `syncWithCrm()`, обработчики событий и пара HTTP-запросов. Тестировать это можно только через HTTP, а любое изменение пугает. Эта статья — о том, как разложить такой код по слоям: **Action-классы** для сценариев, **сервисы** для переиспользуемых операций, **DTO** для данных между ними. И о том, где остановиться, чтобы не превратить простой CRUD в корпоративный монолит из сорока интерфейсов.

**Связанные материалы:** [Антипаттерны проектирования](design-antipatterns) · [Структурные паттерны GoF](structural-design-patterns) · [Отказоустойчивые интеграции](resilient-external-integrations) · [Очереди Laravel в продакшене](laravel-queues-production)

## Содержание

* [Симптомы толстого контроллера и толстой модели](#symptoms)
* [Карта слоёв: кто за что отвечает](#layers)
* [DTO: контракт вместо массива](#dto)
* [Action-классы: один сценарий — один класс](#actions)
* [Сервисный слой: когда он действительно нужен](#services)
* [Контроллер после рефакторинга](#thin-controller)
* [Одна логика — много входов: команды, задачи, тесты](#reuse)
* [Что остаётся в модели](#models)
* [Границы модулей: DTO между контекстами](#modules)
* [Когда это оверинжиниринг](#when-not)
* [Как рефакторить постепенно](#migration)
* [Частые ошибки](#common-mistakes)
* [Чеклист](#checklist)
* [Квиз для самопроверки](#self-test-quiz)

---

<a id="symptoms"></a>
## Симптомы толстого контроллера и толстой модели

Проблема не в количестве строк, а в том, что в одном месте смешаны разные причины для изменений. Признаки, что пора раскладывать код:

* **Логика копируется между точками входа.** Веб-контроллер, API-контроллер и консольная команда делают «почти одно и то же».
* **Тест сценария требует HTTP-запроса.** Чтобы проверить расчёт скидки, приходится логиниться, собирать форму и разбирать редирект.
* **Непонятно, где граница транзакции.** Заказ сохранён, а резерв товара упал — и в базе остался заказ без резерва.
* **Побочные эффекты происходят до коммита.** Письмо «заказ оформлен» ушло, а транзакция откатилась.
* **Модель знает про всё.** `Order` отправляет письма, ходит в CRM и считает налоги. Любая задача требует правки одного и того же файла, а конфликты в Git становятся нормой.
* **Массивы с неизвестной формой.** `$data['items'][0]['qty']` или `$data['items'][0]['quantity']`? Ответ — только в отладчике.

---

<a id="layers"></a>
## Карта слоёв: кто за что отвечает

| Слой | Отвечает за | Не должен |
|------|-------------|-----------|
| **Controller** | Принять HTTP-запрос, вызвать сценарий, вернуть ответ | Считать цены, ходить во внешние API |
| **FormRequest** | Авторизацию и валидацию входа, сборку DTO | Сохранять данные |
| **DTO** | Перенос типизированных данных между слоями | Содержать бизнес-логику и обращаться к БД |
| **Action** | Один бизнес-сценарий, границы транзакции, порядок шагов | Знать про HTTP, сессию, редиректы |
| **Service** | Переиспользуемую операцию или интеграцию (цены, платежи, склад) | Управлять сценарием целиком |
| **Model** | Данные, связи, касты, скоупы, простые производные значения | Отправлять письма, делать HTTP-запросы |
| **Job / Event** | Отложенные и асинхронные побочные эффекты | Возвращать результат в запрос |

Направление зависимостей одно: контроллер → Action → сервисы и модели. Сервис никогда не вызывает Action, а модель не знает ни о тех, ни о других.

---

<a id="dto"></a>
## DTO: контракт вместо массива

DTO (Data Transfer Object) — объект без поведения, который несёт данные через границу слоя. В современном PHP это несколько строк благодаря readonly-классам ([PHP 8.2](../php/8.2)) и продвижению свойств конструктора:

```php
// app/Domain/Orders/Data/OrderLineData.php
namespace App\Domain\Orders\Data;

final readonly class OrderLineData
{
    public function __construct(
        public int $productId,
        public int $quantity,
    ) {}
}
```

```php
// app/Domain/Orders/Data/PlaceOrderData.php
namespace App\Domain\Orders\Data;

use App\Domain\Orders\Enums\DeliveryMethod;

final readonly class PlaceOrderData
{
    /**
     * @param  list<OrderLineData>  $lines
     */
    public function __construct(
        public int $customerId,
        public array $lines,
        public DeliveryMethod $delivery,
        public ?string $promoCode = null,
    ) {}
}
```

Что это даёт по сравнению с массивом:

* **Типы.** `quantity` — всегда `int`, `delivery` — значение enum, а не строка `"courrier"` с опечаткой.
* **Неизменяемость.** Ни один шаг сценария не может «тихо» поправить данные для следующего.
* **Подсказки IDE и статический анализ.** PHPStan/Psalm видят обращение к несуществующему полю ещё до запуска.
* **Независимость от источника.** Action не знает, пришли данные из формы, JSON API, CSV-импорта или теста.

Валидация остаётся в `FormRequest` — это его работа. DTO собирается уже из проверенных данных. Удобно держать сборку рядом с правилами:

```php
// app/Http/Requests/PlaceOrderRequest.php
public function rules(): array
{
    return [
        'lines' => ['required', 'array', 'min:1'],
        'lines.*.product_id' => ['required', 'integer', 'exists:products,id'],
        'lines.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
        'delivery' => ['required', Rule::enum(DeliveryMethod::class)],
        'promo_code' => ['nullable', 'string', 'max:32'],
    ];
}

public function toData(): PlaceOrderData
{
    $validated = $this->validated();

    return new PlaceOrderData(
        customerId: $this->user()->id,
        lines: array_map(
            fn (array $line) => new OrderLineData((int) $line['product_id'], (int) $line['quantity']),
            $validated['lines'],
        ),
        delivery: DeliveryMethod::from($validated['delivery']),
        promoCode: $validated['promo_code'] ?? null,
    );
}
```

> [!NOTE]
> **Нужен ли пакет?** Для десятка DTO хватает ручных readonly-классов. Пакеты вроде `spatie/laravel-data` добавляют автоматическую сборку из запроса, валидацию по атрибутам и трансформацию в JSON. Это удобно, когда DTO сотни и они же служат API-ресурсами, но привязывает доменный слой к пакету. Начинайте с простых классов.

Деньги в DTO храните целым числом в минимальных единицах (копейки, центы) или объектом `Money`, а не `float`: `0.1 + 0.2` в PHP не равно `0.3`.

---

<a id="actions"></a>
## Action-классы: один сценарий — один класс

Action — класс с одним публичным методом, который выполняет один бизнес-сценарий от начала до конца. Название — глагол из языка предметной области: `PlaceOrder`, `CancelSubscription`, `InviteTeamMember`.

```php
// app/Domain/Orders/Actions/PlaceOrder.php
namespace App\Domain\Orders\Actions;

use App\Domain\Orders\Data\PlaceOrderData;
use App\Domain\Orders\Events\OrderPlaced;
use App\Domain\Orders\Services\PriceCalculator;
use App\Domain\Orders\Services\StockReservations;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    public function __construct(
        private PriceCalculator $prices,
        private StockReservations $stock,
    ) {}

    public function handle(PlaceOrderData $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $quote = $this->prices->quote($data->lines, $data->promoCode);

            $order = Order::create([
                'customer_id' => $data->customerId,
                'delivery' => $data->delivery,
                'total_cents' => $quote->totalCents,
                'discount_cents' => $quote->discountCents,
            ]);

            $order->lines()->createMany($quote->linesForStorage());

            // Throws when stock is insufficient, which rolls back the whole order.
            $this->stock->reserve($order);

            // The event implements ShouldDispatchAfterCommit,
            // so listeners never see an order that was rolled back.
            OrderPlaced::dispatch($order->id);

            return $order;
        });
    }
}
```

Ключевые решения в этом коде:

* **Граница транзакции — в Action.** Сценарий знает, какие шаги должны выполниться атомарно. Контроллер об этом не думает, сервисы — тоже.
* **Зависимости через конструктор.** Контейнер Laravel соберёт `PriceCalculator` и `StockReservations` сам, а в тесте их легко подменить.
* **Побочные эффекты после коммита.** Событие с интерфейсом `ShouldDispatchAfterCommit` отправляется только после фиксации транзакции. Для задач очереди то же самое делает `ShouldQueueAfterCommit` или опция `after_commit` соединения.
* **Возвращается результат, а не HTTP-ответ.** Action не знает, что дальше — редирект, JSON или строка в консоли.

```php
// app/Domain/Orders/Events/OrderPlaced.php
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;

final class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(public int $orderId) {}
}
```

Как называть метод — `handle()`, `execute()` или `__invoke()` — вопрос договорённости в команде. Важно, чтобы он был один и одинаковый во всём проекте. `__invoke()` позволяет передавать Action как callable, `handle()` лучше читается при явном вызове.

> [!NOTE]
> **Action может вызывать другой Action**, если это часть сценария: `PlaceOrder` может вызвать `ApplyLoyaltyPoints`. Но следите за транзакциями: вложенный `DB::transaction()` создаёт savepoint, и откат внутреннего не откатывает внешний, если исключение перехвачено.

---

<a id="services"></a>
## Сервисный слой: когда он действительно нужен

Сервис — это класс вокруг одной ответственности, которую используют несколько сценариев: расчёт цен, работа со складом, платёжный шлюз, генерация PDF. Он не знает, зачем его вызвали.

```php
// app/Domain/Payments/Contracts/PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amountCents, string $currency, string $idempotencyKey): PaymentResult;

    public function refund(string $paymentId, int $amountCents): RefundResult;
}
```

```php
// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
}
```

Интерфейс оправдан, когда реализаций больше одной (боевой шлюз и фейк для тестов, два провайдера в разных странах) или когда это граница с внешним миром. Для внутреннего `PriceCalculator` с единственной реализацией интерфейс — лишний файл: конкретный класс так же легко подменить в тесте через `$this->mock()` или `$this->app->instance()`.

Главная опасность сервисного слоя — **God Service**. `OrderService` с методами `create`, `cancel`, `refund`, `export`, `notify`, `recalculate` и сорока зависимостями в конструкторе — это тот же толстый контроллер, только в другой папке. Если в сервисе больше пяти-семи публичных методов, которые не связаны общими данными, это несколько Action-классов, слипшихся в один.

---

<a id="thin-controller"></a>
## Контроллер после рефакторинга

```php
// app/Http/Controllers/OrderController.php
public function store(PlaceOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
    try {
        $order = $placeOrder->handle($request->toData());
    } catch (InsufficientStock $e) {
        return back()->withErrors(['lines' => $e->getMessage()])->withInput();
    }

    return to_route('orders.show', $order);
}
```

Контроллер переводит HTTP в вызов сценария и результат сценария — обратно в HTTP. Доменное исключение `InsufficientStock` превращается в ошибку формы. В API-контроллере то же исключение станет ответом `422`, а логика заказа при этом не меняется.

---

<a id="reuse"></a>
## Одна логика — много входов: команды, задачи, тесты

Тот же Action без изменений вызывается из консольной команды импорта:

```php
// app/Console/Commands/ImportMarketplaceOrders.php
public function handle(MarketplaceClient $client, PlaceOrder $placeOrder): int
{
    foreach ($client->newOrders() as $external) {
        $placeOrder->handle(MarketplaceOrderMapper::toData($external));
    }

    return self::SUCCESS;
}
```

И проверяется тестом без HTTP, сессий и CSRF:

```php
public function test_placing_an_order_reserves_stock(): void
{
    $customer = User::factory()->create();
    $product = Product::factory()->create(['stock' => 10, 'price_cents' => 1500]);

    $order = app(PlaceOrder::class)->handle(new PlaceOrderData(
        customerId: $customer->id,
        lines: [new OrderLineData($product->id, 2)],
        delivery: DeliveryMethod::Courier,
    ));

    $this->assertSame(3000, $order->total_cents);
    $this->assertSame(8, $product->fresh()->stock);
}

public function test_order_is_rolled_back_when_stock_is_insufficient(): void
{
    $product = Product::factory()->create(['stock' => 1]);

    $this->expectException(InsufficientStock::class);

    try {
        app(PlaceOrder::class)->handle(new PlaceOrderData(
            customerId: User::factory()->create()->id,
            lines: [new OrderLineData($product->id, 5)],
            delivery: DeliveryMethod::Pickup,
        ));
    } finally {
        $this->assertDatabaseCount('orders', 0);
    }
}
```

Тесты контроллера после этого становятся тонкими: проверяют валидацию, авторизацию и то, что ответ правильный. Бизнес-правила проверяются один раз — на уровне Action.

---

<a id="models"></a>
## Что остаётся в модели

Цель не в «анемичной модели» без единого метода. Eloquent-модель — хорошее место для всего, что описывает **сами данные**:

* связи (`lines()`, `customer()`), касты (`'delivery' => DeliveryMethod::class`), скоупы (`scopePaid()`);
* простые производные значения без побочных эффектов: `isPaid()`, `canBeCancelledBy(User $user)`, аксессор `total` из `total_cents`;
* инварианты одной сущности, которые не требуют других сервисов.

Чего в модели быть не должно: отправки писем, HTTP-запросов, работы с очередями, сложной логики в observers. Observers особенно коварны: они срабатывают и при импорте, и в сидерах, и в тестах, а из кода сценария их не видно. Если побочный эффект — часть сценария, ему место в Action.

---

<a id="modules"></a>
## Границы модулей: DTO между контекстами

Когда проект растёт, полезно группировать код не по техническим слоям (`Controllers`, `Models`, `Services`), а по предметным областям:

```
app/
├── Domain/
│   ├── Orders/
│   │   ├── Actions/        PlaceOrder, CancelOrder
│   │   ├── Data/           PlaceOrderData, OrderLineData
│   │   ├── Enums/          DeliveryMethod, OrderStatus
│   │   ├── Events/         OrderPlaced
│   │   └── Services/       PriceCalculator, StockReservations
│   ├── Billing/
│   │   ├── Actions/        ChargeOrder, IssueRefund
│   │   ├── Contracts/      PaymentGateway
│   │   └── Data/           PaymentResult
│   └── Catalog/
├── Http/                   controllers and form requests stay framework-shaped
└── Models/
```

Правило для границ: **модуль `Billing` не принимает модель `Order` из модуля `Orders`.** Он получает DTO или идентификатор:

```php
// Inside the Orders module: a listener translates the event into a Billing call.
final class ChargePlacedOrder implements ShouldQueue
{
    public function handle(OrderPlaced $event, ChargeOrder $charge, OrderSummaryQuery $orders): void
    {
        $summary = $orders->summary($event->orderId); // returns a DTO, not a model

        $charge->handle(new ChargeRequestData(
            reference: "order-{$summary->id}",
            amountCents: $summary->totalCents,
            currency: $summary->currency,
        ));
    }
}
```

Почему не модель:

* модель позволяет вызвать `$order->update()` из чужого модуля — и никто не узнает, кто поменял статус;
* ленивые связи превращают обращение к `$order->customer->address` в запросы, о которых модуль `Billing` не подозревает;
* схема таблицы `orders` становится публичным API — её нельзя менять, не проверив все модули.

Это тот же принцип, что и при [переходе от монолита к микросервисам](monolith-to-microservices-architecture): сначала явные границы внутри одного приложения, и только потом — по сети, если это вообще понадобится.

---

<a id="when-not"></a>
## Когда это оверинжиниринг

Слои стоят времени: больше файлов, больше переходов по коду, больше договорённостей. Они не окупаются, если:

* **Это CRUD без правил.** Форма редактирования профиля, которая валидирует и сохраняет пять полей, не нуждается в `UpdateProfileAction` и `UpdateProfileData`. `$user->update($request->validated())` — нормальный код.
* **Это админка на Filament или Nova.** Фреймворк уже задаёт свою структуру, и Action-классы поверх неё часто дублируют его ресурсы.
* **Это прототип.** Пока вы ищете продукт, скорость изменений важнее архитектуры. Рефакторинг в слои — когда сценарий устоялся.
* **У сценария одна точка входа и нет побочных эффектов.** Нечего переиспользовать и нечего изолировать.

Хороший сигнал, что пора: второй вход в тот же сценарий, первое «письмо ушло, а транзакция откатилась» или первый тест, который невозможно написать без HTTP.

---

<a id="migration"></a>
## Как рефакторить постепенно

Переписывать весь проект «на Actions» не нужно и опасно. Работающий путь:

1. **Новые сценарии — сразу в новом стиле.** Это задаёт образец без риска для старого кода.
2. **Перед выносом — характеризующий тест.** Пишется feature-тест на текущее поведение контроллера, даже если оно странное. Он ловит регрессии при переносе.
3. **Выносите по одному сценарию.** Тело метода контроллера переезжает в `handle()` Action почти без изменений, контроллер начинает его вызывать. Тест должен остаться зелёным.
4. **Только потом улучшайте.** Вводите DTO вместо массива, переносите побочные эффекты после коммита, выделяйте сервисы из повторяющихся кусков.
5. **Удаляйте копии.** Второй и третий контроллеры начинают вызывать тот же Action, дубли уходят.

Каждый шаг — отдельный небольшой PR, который можно откатить.

---

<a id="common-mistakes"></a>
## Частые ошибки

**1. Action принимает `Request`.**
Сценарий снова привязан к HTTP, и вызвать его из команды или задачи без фейкового запроса нельзя. Передавайте DTO.

**2. DTO с бизнес-логикой.**
Метод `calculateTotal()` в DTO — это сервис, спрятанный в объект данных. DTO только переносит данные.

**3. Изменяемые DTO.**
Публичные не-readonly свойства позволяют одному шагу поменять данные для другого. Используйте `readonly`.

**4. God Service вместо толстого контроллера.**
`OrderService` на две тысячи строк — та же проблема в новой папке. Сценарии — в Action-классы, сервисы — узкие.

**5. Побочные эффекты внутри транзакции.**
Письма, вебхуки и задачи очереди, отправленные до коммита, срабатывают даже при откате. Используйте `ShouldDispatchAfterCommit`, `ShouldQueueAfterCommit` или `afterCommit()`.

**6. Интерфейс на каждый класс.**
`PriceCalculatorInterface` с единственной реализацией и без внешней границы — лишний уровень косвенности. Laravel умеет подменять конкретные классы в тестах.

**7. Слои ради слоёв.**
`UpdateUserNameAction` с `UpdateUserNameData` для одной строки `$user->update()` — это церемония, а не архитектура.

---

<a id="checklist"></a>
## Чеклист

1. Контроллер только переводит HTTP в вызов сценария и результат — обратно в HTTP.
2. Валидация и авторизация — в `FormRequest`, там же сборка DTO.
3. Один бизнес-сценарий — один Action-класс с одним публичным методом.
4. Граница транзакции определяется в Action.
5. Побочные эффекты (письма, вебхуки, задачи) отправляются после коммита.
6. DTO — `final readonly`, с типами и без логики; деньги не во `float`.
7. Сервисы узкие; интерфейсы — только для внешних границ и нескольких реализаций.
8. Между модулями передаются DTO или идентификаторы, а не модели.
9. Бизнес-правила покрыты тестами на уровне Action, без HTTP.

---

## Итог

Action-классы, сервисы и DTO — не про красоту папок, а про то, чтобы у каждого бизнес-сценария было одно место, одна граница транзакции и один набор тестов. Начинайте с боли — дублирования, побочных эффектов до коммита, невозможности протестировать — и вводите ровно столько слоёв, сколько нужно, чтобы её убрать. Простой CRUD пусть остаётся простым.

---

<a id="self-test-quiz"></a>
## Квиз для самопроверки

### Вопрос 1: Где должна определяться граница транзакции при оформлении заказа?
- А) В контроллере, вокруг вызова Action.
- Б) В Action-классе, который знает, какие шаги сценария должны выполниться атомарно.
- В) В каждом сервисе отдельно — своя транзакция на каждый вызов.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Сценарий знает, что заказ, его строки и резерв товара должны сохраниться вместе. Контроллер не должен знать такие детали, а отдельные транзакции в сервисах не дают атомарности всего сценария.
</details>

### Вопрос 2: Внутри `DB::transaction()` отправляется письмо «Заказ оформлен», затем резерв товара падает с исключением. Что произойдёт без дополнительных мер?
- А) Письмо не уйдёт, потому что транзакция откатилась.
- Б) Письмо уйдёт (или задача на отправку попадёт в очередь), хотя заказа в базе нет.
- В) Laravel автоматически отменит письмо через событие отката.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Откат транзакции касается только базы данных. Внешние побочные эффекты нужно отправлять после коммита: через `ShouldDispatchAfterCommit` у событий, `ShouldQueueAfterCommit` или `afterCommit()` у задач.
</details>

### Вопрос 3: Модулю `Billing` нужно списать деньги за заказ. Что лучше передать ему из модуля `Orders`?
- А) Модель `Order` целиком, чтобы `Billing` мог сам загрузить нужные связи.
- Б) DTO с суммой, валютой и ссылкой на заказ или идентификатор заказа.
- В) Массив `$request->all()` из исходного запроса.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
DTO фиксирует, какие данные нужны модулю, и не даёт ему менять чужие записи или делать скрытые запросы через связи. Схема таблицы `orders` перестаёт быть публичным API для других модулей.
</details>