Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Архітектура Laravel-застосунку

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

Товстий контролер - метод контролера на сотню рядків, де змішано все: валідацію, перевірку прав, бізнес-правила, роботу з базою, відправку листів, виклики сторонніх API, формування відповіді.

public function store(Request $request)
{
    $request->validate([...]);                        // валідація
    if ($request->user()->orders()->count() > 10) {}  // бізнес-правило
    $order = Order::create([...]);                    // збереження
    foreach ($request->items as $item) { /* ... */ }  // розрахунки
    Mail::to($request->user())->send(new OrderPlaced($order));
    Http::post('https://crm.example/api/...', [...]); // інтеграція
    return response()->json(...);
}

Чому це погано: логіку неможливо перевикористати (з команди Artisan, черги, API й адмінки потрібна та сама дія), важко тестувати окремо від HTTP, кожна зміна чіпає великий метод.

Що куди переносити:

Що Куди
валідація й авторизація запиту Form Request (rules(), authorize())
права на конкретні об'єкти політики ($this->authorize('update', $order))
бізнес-операція action-клас чи сервіс (PlaceOrder)
реакції на подію (листи, інтеграції) події й слухачі, часто в черзі
довгі чи ненадійні операції джоби в черзі
форматування відповіді API Resource, view
запити, що повторюються scopes моделі, query-класи

Після рефакторингу контролер - тонкий координатор:

public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
    $order = $placeOrder->handle($request->user(), $request->validated());

    return OrderResource::make($order)->response()->setStatusCode(201);
}

Він знає лише про HTTP: отримати перевірений запит, викликати дію, повернути відповідь.

Не варто впадати в іншу крайність: для простого CRUD без бізнес-логіки Post::create($request->validated()) у контролері - цілком нормально. Окремий клас на кожен рядок коду - теж складність. Виносити варто, коли з'являється реальна логіка або потреба викликати ту саму дію з кількох місць.

Докладніше в документації: Laravel: контролери

Сервіс-контейнер - механізм, що створює об'єкти й автоматично передає їм залежності. Клас оголошує, що йому потрібно, у конструкторі - контейнер підставляє.

final class PlaceOrder
{
    public function __construct(
        private PaymentGateway $payments,
        private InventoryService $inventory,
        private Dispatcher $events,
    ) {}
}

// контролер: контейнер створить PlaceOrder і всі його залежності
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder) { /* ... */ }

Розв'язання без конфігурації: для конкретних класів контейнер сам читає конструктор через рефлексію й рекурсивно створює залежності. Реєструвати нічого не треба.

Коли потрібна реєстрація (у AppServiceProvider::register()):

  • інтерфейс → реалізація:
$this->app->bind(PaymentGateway::class, StripeGateway::class);
  • один екземпляр на застосунок (з'єднання, клієнти API): singleton(); на запит чи задачу черги - scoped();
  • параметри конструктора, яких контейнер не знає (ключі, URL):
$this->app->singleton(StripeGateway::class, fn () => new StripeGateway(config('services.stripe.secret')));
  • різні реалізації для різних споживачів - контекстна прив'язка (when(...)->needs(...)->give(...)) чи атрибути (#[Config('...')], #[Storage('s3')]).

Де контейнер впроваджує залежності автоматично: контролери (конструктор і методи), джоби (handle), слухачі, команди Artisan, middleware, Form Request, політики, Livewire-компоненти.

Навіщо це все:

  • тестування: підміна залежності фейком - $this->app->instance(PaymentGateway::class, new FakeGateway) чи $this->mock(...);
  • слабка зв'язність: клас залежить від інтерфейсу, а конкретну реалізацію обирає конфігурація;
  • явні залежності: з конструктора видно, з чим працює клас.

Чого уникати:

  • app() / resolve() всередині методів (service locator) - залежність прихована, як і в Singleton;
  • логіки в конструкторах (запити до бази, HTTP) - конструктор має лише зберегти залежності;
  • реєстрації всього підряд - для конкретних класів без параметрів вона не потрібна.

Докладніше в документації: Laravel: розв'язання без конфігурації

Подія повідомляє: «сталося X». Слухачі реагують, і відправник про них не знає.

// у дії оформлення замовлення
OrderPlaced::dispatch($order);

// окремо - реакції
class SendOrderConfirmation { public function handle(OrderPlaced $event): void { /* лист */ } }
class NotifyWarehouse implements ShouldQueue { public function handle(OrderPlaced $event): void { /* API складу */ } }
class TrackPurchase implements ShouldQueue { /* аналітика */ }

Коли події доречні:

  • побічні реакції, без яких основна дія все одно успішна: листи, сповіщення, аналітика, оновлення пошукового індексу, кешу, синхронізація зі сторонніми системами;
  • кілька незалежних реакцій на одну подію, які додаються з часом;
  • розв'язка модулів: модуль «Склад» реагує на OrderPlaced з модуля «Замовлення», не будучи залежністю для нього;
  • асинхронність: слухачі з ShouldQueue виконуються в черзі, не сповільнюючи відповідь.

Коли краще прямий виклик:

  • дія - обов'язкова частина сценарію, і її збій має зупинити операцію (списання оплати, резервування товару) - прямий виклик з явною обробкою помилок;
  • результат потрібен одразу у викликаючому коді;
  • одна реакція, яка навряд чи зміниться, - подія лише додасть непрямість.

Ознака надмірного використання: щоб зрозуміти, що відбувається при оформленні замовлення, треба відкрити десять слухачів, які генерують ще події. Потік стає невидимим.

Важливі деталі:

  • транзакції: подія, оброблена до коміту, може надіслати лист про замовлення, яке потім відкотиться. Використовуйте ShouldDispatchAfterCommit для подій і afterCommit / ShouldHandleEventsAfterCommit для слухачів;
  • дані в події - мінімально потрібні (модель чи ідентифікатор), не весь контекст запиту;
  • події моделей (created, updated) зручні, але спрацьовують і в сидерах, імпорті, тестах і не спрацьовують на масових update() - для бізнес-подій краще явні доменні події (OrderPlaced), а не OrderCreated з Eloquent;
  • перелік: php artisan event:list показує, хто на що підписаний.

Докладніше в документації: Laravel: події

Черга відокремлює прийняття запиту від виконання роботи. Веб-запит лише ставить задачу в чергу й одразу відповідає, а воркери виконують її у фоні.

Що варто винести в чергу:

  • повільні операції: генерація PDF і звітів, обробка зображень і відео, імпорт файлів;
  • виклики сторонніх сервісів: листи, SMS, push, вебхуки, синхронізація з CRM - вони можуть бути повільними чи недоступними;
  • масові дії: розсилка тисячам користувачів, перерахунок статистики;
  • те, що не потрібне для відповіді користувачу прямо зараз.

Що черги дають архітектурі:

  • швидкі відповіді: користувач не чекає на відправку листа чи відповідь API банку;
  • стійкість до збоїв: якщо сторонній сервіс недоступний, задача повториться ($tries, backoff), а запит користувача не впаде;
  • згладжування навантаження: пік запитів створює чергу задач, яку воркери обробляють у своєму темпі, а не перевантажує базу й сторонні API;
  • незалежне масштабування: воркерів можна додавати окремо від вебсерверів; різні черги (emails, reports, default) - з різною кількістю воркерів і пріоритетами;
  • обмеження частоти до сторонніх API (RateLimited, ThrottlesExceptions middleware для джоб).

Що треба враховувати при проєктуванні:

  • ідемпотентність: задача може виконатися більше одного разу (повтор після тайм-ауту, перезапуск воркера) - повторне виконання не має дублювати списання чи листи;
  • eventual consistency: результат з'являється не одразу - інтерфейс має показувати стан «в обробці»;
  • транзакції: задача, поставлена всередині транзакції, може запуститися до коміту й не знайти дані - afterCommit();
  • дані в задачі: моделі серіалізуються як ідентифікатори (SerializesModels) і перезавантажуються - стан на момент виконання може відрізнятися від стану на момент постановки;
  • моніторинг: невдалі задачі (failed_jobs), довжина черги, час очікування - Horizon для Redis-черг;
  • деплой: воркери тримають старий код у пам'яті - після деплою php artisan queue:restart.

Драйвери: database (просто, без додаткової інфраструктури), Redis (швидко, з Horizon), SQS та інші керовані сервіси.

Докладніше в документації: Laravel: черги

Eloquent реалізує патерн Active Record: модель одночасно і представляє рядок таблиці, і вміє себе зберігати. Звідси спокуса класти в модель усе - і з часом вона перетворюється на «божественний клас» на тисячі рядків.

Що природно тримати в моделі:

  • опис даних: casts() (дати, енуми, JSON, об'єкти-значення), $fillable, $hidden;
  • зв'язки (hasMany, belongsTo);
  • локальні scopes - повторювані умови запитів: scopePublished, scopeForTeam;
  • аксесори й мутатори - представлення атрибутів (Attribute::make(...));
  • прості доменні методи, що стосуються лише цієї моделі й її стану: $order->isPaid(), $post->publish(), $subscription->isActive().

Що краще винести:

  • бізнес-операції, що зачіпають кілька моделей і зовнішні системи (оформлення замовлення, оплата, повернення) - в action-класи чи сервіси;
  • складні запити й звіти - у query-класи чи окремі класи звітів;
  • виклики сторонніх API, відправка листів - у сервіси, слухачі, джоби;
  • логіка, залежна від HTTP (поточний користувач, запит) - моделі не повинні знати про request() чи auth();
  • форматування для API - в API Resources.

Пастки:

  • події й спостерігачі моделей з бізнес-логікою: «при збереженні замовлення списати товар зі складу» - спрацює і в сидері, і при імпорті, і в тестах, і не спрацює при масовому update();
  • глобальні scopes, що неявно змінюють усі запити (наприклад, мультиорендність) - потужно, але легко забути про них і отримати несподіваний результат;
  • ледаче завантаження у методах моделі ($this->items->sum(...) у циклі) - N+1;
  • моделі як DTO між шарами - зміни в таблиці миттєво стають змінами в усіх шарах.

Корисні інструменти для «схуднення» моделей: власні колекції (newCollection, атрибут #[CollectedBy]), власні будівники запитів (#[UseEloquentBuilder]) для складних scopes, касти для об'єктів-значень, трейти для поведінки, спільної кільком моделям.

Баланс: Active Record - свідомий вибір Laravel заради простоти. Боротися з ним, будуючи повний шар репозиторіїв і доменних сутностей поверх Eloquent, зазвичай дорожче, ніж тримати моделі охайними за правилами вище.

Докладніше в документації: Laravel: Eloquent

Action-клас - клас, що виконує одну бізнес-операцію і має один публічний метод (handle() чи __invoke()):

final class PlaceOrder
{
    public function __construct(
        private InventoryService $inventory,
        private PaymentGateway $payments,
    ) {}

    public function handle(User $user, array $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $order = $user->orders()->create([...]);
            $this->inventory->reserve($order);
            $this->payments->authorize($order);
            OrderPlaced::dispatch($order);

            return $order;
        });
    }
}

Сервіс - клас, що групує кілька операцій навколо однієї області: OrderService з place(), cancel(), refund(), ship().

Переваги action-класів:

  • назва = бізнес-операція: PlaceOrder, CancelSubscription, InviteTeamMember - структура папки app/Actions читається як перелік того, що вміє застосунок;
  • маленькі й сфокусовані - не розростаються до «сервісу на 2000 рядків», який з часом обростає всім, що стосується замовлень;
  • точні залежності: кожен action впроваджує лише те, що йому потрібно;
  • виклик звідусіль: контролер, команда Artisan, джоба, Livewire-компонент, тест - та сама дія;
  • легко тестувати: одна операція - один набір тестів.

Коли сервіс доречніший:

  • спільний стан чи конфігурація для групи пов'язаних операцій (клієнт стороннього API з методами для різних ендпойнтів);
  • обгортка над інфраструктурою: ExchangeRateService, GeoIpService - не бізнес-операції, а технічні можливості;
  • кілька дрібних операцій, які окремими класами створили б шум.

Практики, що добре працюють разом:

  • action приймає перевірені дані (масив з validated() чи DTO), а не Request - щоб працювати поза HTTP;
  • транзакція - межа action-а;
  • побічні реакції - через події, а не прямими викликами в action;
  • action може викликати інші actions, але глибокі ланцюжки - сигнал, що межі обрано невдало.

Цей підхід використовують офіційні стартові набори й Fortify (app/Actions/Fortify/CreateNewUser). Пакет lorisleiva/laravel-actions дає змогу одному класу бути і контролером, і джобою, і командою - зручно, але додає «магії».

Докладніше в документації: Laravel: контролери однієї дії

Repository (за Фаулером) - посередник між доменом і даними, що поводиться як колекція доменних об'єктів: додати, знайти, видалити, - приховуючи, як і де вони зберігаються.

Типова реалізація в Laravel-проєктах:

interface OrderRepository
{
    public function find(int $id): ?Order;
    public function forUser(User $user): Collection;
    public function save(Order $order): void;
}

final class EloquentOrderRepository implements OrderRepository { /* ... */ }

Аргументи «за»:

  • запити в одному місці: складні запити не розкидані по контролерах і сервісах;
  • підміна в тестах: бізнес-логіку можна тестувати з репозиторієм у пам'яті, без бази;
  • незалежність від сховища: теоретично можна замінити Eloquent чи базу.

Аргументи «проти» в контексті Laravel:

  • Eloquent - уже Active Record: модель сама вміє зберігатися й будувати запити. Репозиторій, що повертає ті самі моделі Eloquent, не приховує сховища: модель усе одно має save(), delete() і ледаче завантаження зв'язків;
  • «дірява» абстракція: методи на кшталт findByStatusAndDateAndUserWithRelations() множаться, бо Eloquent-будівник запитів гнучкіший за будь-який інтерфейс;
  • заміна бази - рідкісний сценарій, а заміна ORM у Laravel-проєкті - ще рідший;
  • тестування з базою в Laravel дешеве (SQLite в пам'яті, транзакції, фабрики), тож аргумент «тести без бази» слабшає;
  • більше коду: інтерфейс + реалізація + реєстрація в контейнері на кожну модель.

Компромісні рішення, що дають більшу частину користі:

  • scopes і власні будівники запитів (#[UseEloquentBuilder]) - повторювані умови в одному місці;
  • query-класи для складних запитів і звітів (MonthlyRevenueQuery);
  • action-класи - бізнес-операції, що працюють з моделями напряму.

Коли репозиторій справді виправданий:

  • доменна модель окремо від Eloquent (DDD з чистими сутностями, які не знають про базу) - тоді репозиторій обов'язковий: він перетворює рядки бази на доменні об'єкти;
  • дані з кількох джерел за одним інтерфейсом (база + зовнішній API + кеш);
  • модульний моноліт, де модуль не повинен віддавати свої моделі назовні.

Для співбесіди: важливо показати розуміння компромісу, а не «завжди» чи «ніколи».

Докладніше в документації: Мартін Фаулер: Repository

DTO (Data Transfer Object) - простий об'єкт для передачі даних між шарами застосунку: типізовані поля без бізнес-поведінки.

Проблема масивів:

public function handle(array $data): Order
{
    $data['customer_id'];   // а чи є такий ключ? який тип? як він називається - customerId?
}

Масив не має контракту: невідомо, які ключі обов'язкові, яких типів, - доводиться шукати, звідки він прийшов. Помилки в назвах ключів знаходяться лише під час виконання.

DTO на сучасному PHP:

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

    public static function fromRequest(StoreOrderRequest $request): self
    {
        return new self(
            customerId: $request->user()->id,
            lines: array_map(OrderLineData::fromArray(...), $request->validated('items')),
            comment: $request->validated('comment'),
            delivery: DeliveryMethod::from($request->validated('delivery')),
        );
    }
}

Що дає DTO:

  • явний контракт: з сигнатури handle(PlaceOrderData $data) видно, що потрібно операції;
  • типи й підказки IDE, перевірка статичним аналізом;
  • незмінність (readonly): дані не зміняться непомітно по дорозі;
  • незалежність від джерела: та сама дія викликається з HTTP (fromRequest), команди Artisan, імпорту CSV, тестів;
  • енуми й об'єкти-значення замість рядків.

Де DTO корисні найбільше:

  • вхід бізнес-операцій (action-класи, сервіси);
  • відповіді сторонніх API - перетворити JSON на типізовані об'єкти одразу на межі, а не тягнути масиви по коду;
  • межі модулів - модуль віддає DTO, а не свої моделі Eloquent.

Інструменти:

  • readonly-класи PHP 8.2+ з властивостями конструктора - достатньо для більшості випадків;
  • spatie/laravel-data - DTO з вбудованою валідацією, перетворенням з/у масив, Request, моделі, генерацією TypeScript-типів;
  • Form Request + метод toDto().

Чого уникати: DTO на кожну модель «про всяк випадок», які дублюють поля моделі один в один, - це шар без користі. DTO вводять там, де потрібен контракт між частинами коду.

Докладніше в документації: PHP: readonly-класи

Стандартна структура Laravel групує код за технічним типом: усі контролери в Http/Controllers, усі моделі в Models. У великому застосунку зміна однієї функції (наприклад, оплати) зачіпає десяток папок, а межі між частинами системи не видно.

Модульний моноліт групує код за предметними областями, лишаючись одним застосунком з одним деплоєм:

app/
  Modules/
    Billing/
      Actions/ Models/ Http/ Events/ Listeners/ Jobs/
      BillingServiceProvider.php
      routes.php
    Catalog/
    Orders/
    Shared/        ← спільні об'єкти-значення, базові класи

Кожен модуль:

  • має власний сервіс-провайдер, що реєструє маршрути, слухачі, прив'язки в контейнері, міграції (loadMigrationsFrom), конфігурацію;
  • володіє своїми таблицями - інші модулі не пишуть у них напряму;
  • має публічний інтерфейс - action-класи, контракти, події, DTO, - а все інше вважається внутрішнім.

Як модулі взаємодіють:

  • виклик публічного контракту: Orders викликає Billing\Contracts\ChargesCustomers, а не лізе в моделі Billing;
  • події: Orders публікує OrderPlaced, Billing і Inventory підписуються - без прямої залежності;
  • ідентифікатори замість моделей на межах: модуль передає customerId, а не модель Customer з іншого модуля.

Як утримати межі - найскладніша частина:

  • архітектурні тести (Pest arch()): «код з Modules\Catalog не використовує Modules\Billing\Models»;
  • зв'язки Eloquent між модулями - найчастіший спосіб непомітно зламати межі. Їх обмежують чи замінюють запитами через публічний інтерфейс модуля;
  • рев'ю коду з увагою до нових залежностей між модулями.

Переваги перед мікросервісами: один деплой, одна база (з логічним розділенням), транзакції без розподілених протоколів, простий рефакторинг. А модуль з чіткими межами можна винести в окремий сервіс пізніше - коли з'явиться реальна причина.

Коли це потрібно: великий застосунок, кілька команд, складна предметна область. Для невеликого проєкту стандартна структура Laravel простіша і зрозуміліша будь-якому новому розробнику.

Інструменти: пакети на кшталт nwidart/laravel-modules чи internachi/modular дають генератори й автозавантаження модулів, але суть - у дисципліні меж, а не в структурі папок.

Докладніше в документації: Laravel: сервіс-провайдери

Фасад - статичний «інтерфейс» до об'єкта з контейнера: Cache::get(), Mail::to(), Http::get(). За кулісами Cache звертається до контейнера й викликає метод справжнього об'єкта.

// фасад
final class ExchangeRates
{
    public function rate(string $currency): float
    {
        return Cache::remember("rate:$currency", 3600, fn () => Http::get(...)->json('rate'));
    }
}

// впровадження залежностей
final class ExchangeRates
{
    public function __construct(
        private CacheRepository $cache,
        private HttpFactory $http,
    ) {}
}

Тестування - обидва способи працюють. Попри статичний синтаксис, фасади Laravel підмінюються:

Cache::shouldReceive('remember')->once()->andReturn(41.5);
Http::fake(['bank.example/*' => Http::response(['rate' => 41.5])]);
Mail::fake();
Queue::fake();

Це головна відмінність від справжніх статичних класів: фасад - не глобальний стан, а точка доступу до контейнера.

Аргументи на користь впровадження залежностей:

  • явні залежності: з конструктора видно, з чим працює клас. Клас з десятком фасадів у методах може приховувати десяток залежностей;
  • «запах» розростання: конструктор з вісьмома параметрами одразу показує, що клас робить забагато; фасади ховають цю проблему;
  • незалежність від фреймворку: клас з інтерфейсами в конструкторі можна використати поза Laravel (бібліотека, пакет);
  • статичний аналіз і IDE краще розуміють впроваджені залежності.

Аргументи на користь фасадів:

  • лаконічність - особливо в контролерах, маршрутах, Blade, коді, що не перевикористовується;
  • готові фейки (Mail::fake(), Storage::fake(), Bus::fake()) з виразними перевірками;
  • ідіоматичність: документація й більшість Laravel-коду використовують фасади.

Практичний підхід, поширений у командах:

  • фасади - у «краях» застосунку: контролери, маршрути, команди, конфігурація, тести;
  • впровадження через конструктор - у класах бізнес-логіки (actions, сервіси, домен), де важлива явність залежностей;
  • не змішувати обидва підходи в одному класі без причини.

Real-time фасади (use Facades\App\Services\Rates;) дають статичний доступ до будь-якого класу - зручно, але ще більше ховають залежності.

Докладніше в документації: Laravel: фасади чи впровадження залежностей

Illuminate\Pipeline - механізм, на якому побудовані middleware: об'єкт проходить послідовністю «труб», кожна може змінити його, передати далі чи зупинити процес.

use Illuminate\Support\Facades\Pipeline;

$order = Pipeline::send($order)
    ->through([
        EnsureItemsAvailable::class,
        ApplyPromoCode::class,
        CalculateShipping::class,
        CalculateTaxes::class,
    ])
    ->thenReturn();
final class ApplyPromoCode
{
    public function __construct(private PromoCodes $promoCodes) {}

    public function handle(Order $order, Closure $next): Order
    {
        if ($order->promo_code) {
            $order->discount = $this->promoCodes->discountFor($order);
        }

        return $next($order);
    }
}

Кроки створюються контейнером - залежності впроваджуються автоматично. Крок може бути класом, замиканням чи рядком Class:параметр.

Де пайплайн доречний:

  • багатокрокова обробка одного об'єкта: розрахунок ціни замовлення, підготовка імпортованого рядка (нормалізація → валідація → збагачення → збереження), обробка завантаженого файлу;
  • фільтри запитів: кожен фільтр каталогу (ціна, категорія, наявність) - крок, що додає умову до будівника запитів;
  • набір кроків змінюється за конфігурацією чи контекстом (різні правила для країн, тарифів).

Переваги:

  • кожен крок - маленький клас з однією відповідальністю, тестується окремо;
  • порядок і склад кроків видно в одному місці;
  • новий крок не змінює наявні.

Корисні можливості:

  • ->via('process') - інша назва методу кроків замість handle;
  • ->then(fn ($order) => ...) - фінальна дія після всіх кроків;
  • транзакція навколо всього пайплайну - Pipeline::send(...)->withinTransaction() (у нових версіях Laravel) або DB::transaction навколо виклику;
  • умовні кроки - збирати масив кроків за умовами перед through().

Пастки:

  • приховані залежності між кроками: крок 4 очікує, що крок 2 заповнив поле. Порядок стає неявним контрактом - його варто документувати чи перевіряти;
  • мутація спільного об'єкта: кроки змінюють той самий об'єкт - важко зрозуміти, хто що змінив. Для складних процесів - незмінні об'єкти, які кожен крок повертає новими;
  • надмірність: три рядки лінійного коду не потребують пайплайну з трьох класів;
  • обробка помилок: виняток у середині зупиняє весь процес - має бути зрозуміло, що відбувається з уже зробленими змінами.

Докладніше в документації: Laravel: Pipeline

Проблема: всередині транзакції відправляються події чи ставляться задачі в чергу, але транзакція ще не закомічена.

DB::transaction(function () use ($data) {
    $order = Order::create($data);
    OrderPlaced::dispatch($order);              // слухачі виконуються ЗАРАЗ
    SendInvoice::dispatch($order);              // воркер може взяти задачу ДО коміту
    $this->payments->charge($order);            // а тут виняток - усе відкочено
});

Що піде не так:

  • лист «Ваше замовлення оформлено» про замовлення, якого немає - транзакцію відкочено, а лист уже надіслано;
  • воркер не знаходить запис: задача з черги стартує до коміту, Order::find() повертає null - ModelNotFoundException;
  • зовнішні системи отримали дані, яких у базі немає (вебхук, синхронізація з CRM).

Інструменти Laravel:

  • ShouldDispatchAfterCommit на класі події - подія відправляється лише після успішного коміту (і зовсім не відправляється при відкоті);
  • ShouldHandleEventsAfterCommit на слухачі - слухач виконується після коміту;
  • afterCommit() на джобі чи after_commit => true у конфігурації з'єднання черги - задача потрапляє в чергу лише після коміту;
  • DB::afterCommit(fn () => ...) - будь-яка дія після коміту поточної транзакції.
final class OrderPlaced implements ShouldDispatchAfterCommit { /* ... */ }

SendInvoice::dispatch($order)->afterCommit();

Чого це не вирішує: «після коміту» - не те саме, що «гарантовано». Якщо процес упаде між комітом і відправкою в чергу, подія втрачена: дані в базі є, а реакції - ні. Для критичних інтеграцій (оплати, облік) потрібен transactional outbox:

  1. в тій самій транзакції, що й зміна даних, записати повідомлення в таблицю outbox;
  2. окремий процес читає outbox і відправляє повідомлення (в чергу, брокер, вебхук), позначаючи відправлені;
  3. споживачі - ідемпотентні, бо повідомлення може прийти двічі.

Так зміна даних і факт «треба повідомити» атомарні - або обидва є, або жодного.

Інші правила:

  • зовнішні виклики (HTTP, платежі) не робити всередині транзакції - транзакція тримає блокування весь час очікування відповіді, а відкотити зовнішню дію неможливо;
  • транзакція - якомога коротша і лише навколо змін бази;
  • тести: RefreshDatabase обгортає тест у транзакцію, тож «після коміту» в тестах поводиться особливо - Laravel це враховує, але сценарії з відкотом варто перевіряти явно.

Докладніше в документації: Laravel: події після транзакцій

Мультиорендність (multi-tenancy) - один застосунок обслуговує багато клієнтів-орендарів (компанії, школи, магазини), і дані кожного мають бути ізольовані від інших.

Три основні моделі зберігання:

1. Спільна база, колонка tenant_id:

#[ScopedBy(TenantScope::class)]
class Project extends Model {}

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('tenant_id', app(CurrentTenant::class)->id);
    }
}
  • плюси: просто, дешево, одна міграція на всіх, легко робити звіти по всіх орендарях;
  • мінуси: ізоляція тримається на коді - один забутий фільтр (сирий запит, withoutGlobalScopes, джоба без контексту орендаря) - і дані одного клієнта бачить інший. «Галасливий сусід» навантажує базу для всіх.

Посилення: Row-Level Security у PostgreSQL - політика на рівні бази (USING (tenant_id = current_setting('app.tenant_id')::int)): навіть помилка в застосунку не поверне чужих рядків. Застосунок встановлює змінну сесії бази на кожен запит.

2. Окрема схема на орендаря (PostgreSQL schemas): ізоляція сильніша, бекап і відновлення окремого клієнта простіші, але міграції треба проганяти по всіх схемах, а тисячі схем ускладнюють обслуговування.

3. Окрема база на орендаря: найсильніша ізоляція, окремі ресурси, можливість розмістити великого клієнта на окремому сервері чи в іншому регіоні (вимоги до зберігання даних). Ціна - складна інфраструктура, міграції по сотнях баз, з'єднання, крос-орендна аналітика.

Що треба вирішити незалежно від моделі:

  • визначення поточного орендаря: піддомен (acme.app.com), домен, шлях, обраний у сесії - і перевірка, що користувач належить орендарю;
  • контекст у фонових процесах: джоби, команди, планувальник не мають запиту - ідентифікатор орендаря передається явно в задачу й відновлюється перед виконанням;
  • кеш, файли, черги, пошук - ключі й шляхи з префіксом орендаря, інакше витік через кеш;
  • унікальність - unique(['tenant_id', 'email']), а не глобальна;
  • тести ізоляції: для кожного ресурсу - «орендар A не бачить даних орендаря B».

Як обрати: більшість SaaS починає зі спільної бази з tenant_id (+ RLS для критичних даних) і переходить до окремих баз лише для великих клієнтів чи регуляторних вимог. Пакети stancl/tenancy і spatie/laravel-multitenancy реалізують обидва підходи.

Докладніше в документації: PostgreSQL: політики безпеки рядків

Звичайний PHP-FPM: на кожен запит фреймворк завантажується з нуля і після відповіді все знищується. Будь-який стан живе рівно один запит - це «безкоштовна» ізоляція.

Octane (FrankenPHP, Swoole, RoadRunner) завантажує застосунок один раз і обробляє ним тисячі запитів. Звідси приріст швидкості - і нові класи помилок: стан переживає запит.

Що ламається:

1. Сінглтони, що захопили дані запиту:

// погано: сінглтон створено при першому запиті - і він назавжди тримає ТОГО користувача
$this->app->singleton(CartService::class, fn ($app) => new CartService($app['request']->user()));

Наступні запити інших користувачів отримають кошик першого. Рішення - не впроваджувати запит, користувача, конфігурацію, що змінюється, у конструктори сінглтонів; передавати їх у методи; або використовувати scoped() - екземпляр на запит.

2. Статичні властивості й статичні кеші (static $cache = []) накопичують дані між запитами - витоки пам'яті й даних між користувачами.

3. Впровадження контейнера чи запиту в сінглтон - сінглтон отримає контейнер першого запиту. Документація Octane радить замикання-резолвери (fn () => app('request')) замість прямого впровадження.

4. Витоки пам'яті: масиви, що лише ростуть (логування в статичний масив, реєстрація слухачів на кожен запит). Octane перезапускає воркери після N запитів (--max-requests), але це страховка, а не рішення.

5. З'єднання з базою й сторонніми сервісами живуть довго - потрібна обробка розірваних з'єднань.

Що варто переглянути в коді:

  • усі singleton() у сервіс-провайдерах - чи не тримають вони стан запиту;
  • статичні змінні в класах застосунку й пакетів;
  • пакети, не сумісні з Octane (зберігають стан у статичних властивостях);
  • код, що покладається на «чистий» старт (глобальні змінні, define).

Нові можливості:

  • кешування на рівні воркера (Octane::table(), кеш у пам'яті) для дуже гарячих даних;
  • паралельні задачі (Octane::concurrently() на Swoole) чи Concurrency::run();
  • фонові «тікери» на Swoole.

Тести: логіка, що працює в звичайних тестах, може ламатися лише під Octane - варто запускати навантажувальні тести з кількома користувачами й перевіряти ізоляцію даних.

Чи потрібен Octane: якщо основний час відповіді - запити до бази й сторонні API, Octane дасть менше, ніж оптимізація запитів. Він найкорисніший, коли значну частину часу займає завантаження фреймворку.

Докладніше в документації: Laravel Octane: впровадження залежностей