<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · SENIOR

Як структурувати великий Laravel-проєкт: модулі, дії, сервіси, DDD?

Стандартної структури вистачає, доки логіка сценарію вміщається в контролер плюс модель; далі вузьке місце — не назви тек, а те, що бізнес-правило не має одного власника. Дія з одним публічним методом і незмінним DTO на вході дає цього власника без DDD, а тактичні патерни DDD додають лише там, де є інваріанти, які треба захищати, і мова, якою говорить бізнес.

У нас `app/Services` розрісся до шістдесяти класів, `UserService` на 900 рядків — з чого починати рефакторинг?
Коли ви заводите теку `Domain` чи `modules/`, а коли достатньо стандартних `app/Models` і `app/Http`?
Чим Action відрізняється від сервісу і навіщо поруч ще DTO, якщо є Form Request?
Навіщо репозиторій над Eloquent, якщо Eloquent сам по собі Active Record?
архітектура actions DTO модулі DDD шари

Почніть з того, чого стандартна структура насправді не робить. Laravel не нав'язує архітектури: app/ — це просто PSR-4-неймспейс App\, і в Laravel 11 скелет ще й помітно схуднув — зникли app/Http/Middleware/*, app/Console/Kernel.php та app/Exceptions/Handler.php, їхні налаштування переїхали в bootstrap/app.php (withRouting(), withMiddleware(), withExceptions()), а список провайдерів застосунку — у bootstrap/providers.php. Тобто фреймворк уже не диктує навіть тек: php artisan make:controller, make:class, make:enum створюють їх на вимогу, а App\Domain\Billing працюватиме без жодного рядка конфігурації. Через це «великий проєкт розвалився» майже ніколи не означає «не вистачило папок». Означає воно інше: у бізнес-сценарію немає власника. Створення підписки живе трьома рядками в контролері, п'ятьма в Observer, ще трьома в джобі й окремою гілкою в консольній команді, яка робить те саме для імпорту — і коли правило змінюється, треба знайти всі чотири місця.

Стандартної структури — тонкий контролер, Form Request, модель зі скоупами, ресурс на виході — вистачає рівно доти, доки сценарій вміщається в один читабельний метод контролера. Це не «для маленьких проєктів»: у великому застосунку більшість ендпоїнтів так і виглядають, і загортати Model::create($request->validated()) у три класи — чиста втрата. Симптоми, за якими справді пора рухатись далі, конкретні: той самий сценарій викликається більш ніж з одного входу (HTTP, черга, консоль, вебхук); у контролері з'явилася транзакція разом із зовнішнім викликом; ви не можете відповісти на питання «де в коді створюється підписка» одним файлом. Зверніть увагу, що жоден із них не про кількість рядків.

Наступна сходинка — дії. Дія це клас на один сценарій із дієсловом у назві та одним публічним методом: StartSubscription::handle(), CancelOrder::handle(). Laravel сам користується цим патерном — Jetstream кладе app/Actions/Fortify/CreateNewUser.php, і це той самий підхід. Ключова відмінність від звичного app/Services: там клас іменують за сутністю (UserService), тому він приймає все, що стосується користувача, і за рік перетворюється на god-клас без будь-якої межі всередині. Дія такої межі не втрачає — щоб додати логіку, треба або змінити наявний сценарій, або завести новий клас. Дія ж володіє і небезпечними частинами: DB::transaction() живе всередині неї, а не в контролері, бо межа консистентності — властивість сценарію; події диспатчаться після коміту, інакше черга підхопить джобу раніше, ніж транзакція завершиться (для черг це лікується опцією after_commit у config/queue.php або $afterCommit на слухачі, але надійніше просто не кидати подію з Observer моделі).

Вхід дії варто описувати DTO, і це не бюрократія, а різниця в тому, звідки сценарій можна викликати. Form Request — HTTP-об'єкт: він валідує вхід, авторизує, живе в Illuminate\Foundation\Http і потребує реального запиту; передати його в консольну команду чи в ShouldQueue-джобу неможливо. DTO — це final readonly class з іменованими типізованими властивостями (PHP 8.2 дав readonly class, 8.4 — property hooks і асиметричну видимість, якщо потрібні обчислювані поля), який будують з validated(), з CSV-рядка чи з payload вебхука однаково. Дає він три речі: сигнатуру, яку перевіряє PHPStan замість array $data, стабільність при рефакторингу (перейменування поля ламає компіляцію, а не мовчки віддає null) і можливість тестувати сценарій без HTTP взагалі. Якщо ручних DTO багато, spatie/laravel-data замінює їх класами Data з from(), мапінгом імен, виведенням правил валідації з типів і генерацією TypeScript — ціною ще однієї залежності та трохи магії.

Модулі — це вже про залежності між частинами, а не про сценарії. Технічно модуль у Laravel дешевий: другий PSR-4-префікс у composer.json, composer dump-autoload, власний сервіс-провайдер із loadRoutesFrom(), loadMigrationsFrom(), loadViewsFrom() і біндінгами в register(). Дорога частина — дисципліна: без явного правила «Billing спілкується з Catalog лише через опублікований контракт або подію» модулі за півроку зростуться в один. Тому правило має бути виконуваним — pest-plugin-arch (expect('App\Domain')->not->toUse('Illuminate')) для шарів усередині модуля, Deptrac для міжмодульних залежностей, і обидва в CI. Ділити варто за бізнес-спроможностями (Billing, Catalog, Hiring), а не за шарами: modules/Controllers — це стандартна структура з зайвим рівнем вкладеності.

Межа з DDD проходить не там, де з'являються теки Domain/Application/Infrastructure — це шарувата архітектура, і сама по собі вона до DDD відношення не має. Тактичні патерни (агрегати з інваріантами, value objects, репозиторії над власними доменними об'єктами, доменні події) окуповуються за двох умов одночасно: у домені є правила, які треба захищати від некоректних станів, і є мова, якою бізнес справді говорить і яка живе в коді. Тоді ціна — подвійна модель (доменний об'єкт плюс Eloquent для персистентності), ручний мапінг і заборона на Illuminate у домені — купує безпеку змін. Якщо ж правило звучить як «статус із чотирьох значень, при переході в paid шлемо лист», агрегат нічого не захищає, а лише додає класів. Практичний компроміс для великого Laravel-застосунку зазвичай такий: більшість модулів — звичайний Laravel з діями й DTO, Eloquent прямо як модель даних; один-два контексти, де складність реальна, — з повноцінним доменним шаром і арх-тестом, що не пускає туди фреймворк. Про те, як розпізнати такий контекст і що дає стратегічна частина DDD, докладніше в картці [[architecture/when-ddd-helps]].

// DTO: незмінний контракт сценарію. Без Illuminate — його однаково будує
// контролер, консольна команда й споживач черги.
final readonly class NewSubscription
{
    public function __construct(
        public int $customerId,
        public string $planCode,
        public ?string $promoCode = null,
    ) {}
}

// Дія: один сценарій, один публічний метод, залежності через конструктор.
final class StartSubscription
{
    public function __construct(private PaymentGateway $gateway) {}

    public function handle(NewSubscription $input): Subscription
    {
        // Межа транзакції належить дії: не контролеру й не обзерверу моделі.
        $subscription = DB::transaction(function () use ($input): Subscription {
            $customer = Customer::query()
                ->whereKey($input->customerId)
                ->lockForUpdate()   // паралельний запит не створить другу підписку
                ->firstOrFail();

            $charge = $this->gateway->charge($customer, $input->planCode);

            return $customer->subscriptions()->create([
                'plan_code' => $input->planCode,
                'charge_id' => $charge->id,
            ]);
        });

        // Після коміту: слухач не прочитає стан, якого ще немає в базі.
        SubscriptionStarted::dispatch($subscription);

        return $subscription;
    }
}

final class SubscriptionController
{
    // Контейнер сам збудує дію за тайп-хінтом — фасади й new тут не потрібні.
    public function store(StoreSubscriptionRequest $request, StartSubscription $action): JsonResponse
    {
        $data = $request->validated();
        $subscription = $action->handle(new NewSubscription(
            customerId: $request->user()->id,
            planCode: $data['plan'],
            promoCode: $data['promo'] ?? null,
        ));

        return SubscriptionResource::make($subscription)->response()->setStatusCode(201);
    }
}
Що стандартна структура — це не «для маленьких проєктів», а розумний дефолт, і замінюють її за симптомом (сценарій розповзся по контролеру, обзервері, джобі й команді), а не за розміром репозиторію.
Що `app/Services` без правил швидко стає набором god-класів: критерій — один клас на один сценарій із дієсловом у назві (`StartSubscription`), а не один клас на сутність (`UserService`).
Що DTO і Form Request вирішують різні задачі: Form Request валідує HTTP-вхід і живе в `Illuminate`, DTO — типізований контракт сценарію, який однаково приходить з HTTP, консольної команди й черги.
Що дія володіє межею транзакції й побічними ефектами: `DB::transaction()` усередині дії, події — після коміту, а не з обзервера моделі.
Що технічно модуль у Laravel — це PSR-4-неймспейс у `composer.json` плюс власний сервіс-провайдер із `loadRoutesFrom()`/`loadMigrationsFrom()`, і що межі тримаються тестами (`pest-plugin-arch`, Deptrac), а не домовленістю.
Де межа з DDD: агрегати, репозиторії й доменні події виправдані там, де є інваріанти й ubiquitous language; для CRUD це п'ять класів на одну форму.
Починати рефакторинг з тек: створити `Domain/Application/Infrastructure`, перекласти туди ті самі моделі й вважати, що це DDD.
Робити `UserService`, `OrderService`, `ProductService` — один клас на таблицю; через рік це ті самі контролери, лише без HTTP.
Вважати Form Request доменним DTO і тягнути `App\Http\Requests\...` у джобу чи консольну команду — сценарій стає невикликуваним поза HTTP.
Загортати Eloquent у репозиторій, який повертає `Builder`: абстракція протікає повністю, а `with()` і скоупи стають недоступними в місці виклику.
Ставити `DB::transaction()` у контролері й кидати події з `Observer` моделі — слухач стартує до коміту й читає стан, якого ще немає.
Створити теку `modules/` і чекати, що Laravel її підхопить: без запису в `autoload.psr-4` і `composer dump-autoload` жоден клас не завантажиться.
Робити модулі «за шаром» (`modules/Controllers`, `modules/Models`) — це стандартна структура з зайвим рівнем вкладеності.
Заводити інтерфейс до кожного класу «щоб мокати»: одна реалізація й один мок замість тесту на реальному сценарії.
Переносити доменний код у `Domain`, але лишати в ньому `auth()`, `config()` і `now()` — глобальні хелпери прив'язують шар до фреймворка не гірше за `use Illuminate\...`.
ПОРАДА

Сформулюйте критерій, а не структуру: «у кожного бізнес-сценарію має бути рівно одне місце, куди по нього приходять». Далі покажіть сходинки — контролер плюс модель → дія з DTO → модуль зі своїм провайдером і арх-тестом → тактичний DDD у тому одному контексті, де є інваріанти. І назвіть приклад, де ви свідомо зупинилися на першій сходинці.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Тека сама по собі нічого не дає: класи бачить Composer через `autoload.psr-4` після `composer dump-autoload`, а маршрути й міграції модуля підключає його сервіс-провайдер. Form Request — це HTTP-об'єкт з `Illuminate\Foundation\Http`, він потребує запиту й недоступний у команді чи джобі, тому DTO будують з `validated()`, а не передають сам request. Репозиторій над Active Record найчастіше або дублює модель, або протікає через `Builder`; заміна сховища — рідкісна потреба, а не аргумент за замовчуванням. Правильний варіант описує робочий набір: DTO на вході, транзакція й побічні ефекти під контролем дії, автоматичне впровадження залежностей контейнером.