Питання на співбесіді: Архітектура 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()) у контролері - цілком нормально. Окремий клас на кожен рядок коду - теж складність. Виносити варто, коли з'являється реальна логіка або потреба викликати ту саму дію з кількох місць.
Сервіс-контейнер - механізм, що створює об'єкти й автоматично передає їм залежності. Клас оголошує, що йому потрібно, у конструкторі - контейнер підставляє.
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показує, хто на що підписаний.
Черга відокремлює прийняття запиту від виконання роботи. Веб-запит лише ставить задачу в чергу й одразу відповідає, а воркери виконують її у фоні.
Що варто винести в чергу:
- повільні операції: генерація PDF і звітів, обробка зображень і відео, імпорт файлів;
- виклики сторонніх сервісів: листи, SMS, push, вебхуки, синхронізація з CRM - вони можуть бути повільними чи недоступними;
- масові дії: розсилка тисячам користувачів, перерахунок статистики;
- те, що не потрібне для відповіді користувачу прямо зараз.
Що черги дають архітектурі:
- швидкі відповіді: користувач не чекає на відправку листа чи відповідь API банку;
- стійкість до збоїв: якщо сторонній сервіс недоступний, задача повториться (
$tries,backoff), а запит користувача не впаде; - згладжування навантаження: пік запитів створює чергу задач, яку воркери обробляють у своєму темпі, а не перевантажує базу й сторонні API;
- незалежне масштабування: воркерів можна додавати окремо від вебсерверів; різні черги (
emails,reports,default) - з різною кількістю воркерів і пріоритетами; - обмеження частоти до сторонніх API (
RateLimited,ThrottlesExceptionsmiddleware для джоб).
Що треба враховувати при проєктуванні:
- ідемпотентність: задача може виконатися більше одного разу (повтор після тайм-ауту, перезапуск воркера) - повторне виконання не має дублювати списання чи листи;
- eventual consistency: результат з'являється не одразу - інтерфейс має показувати стан «в обробці»;
- транзакції: задача, поставлена всередині транзакції, може запуститися до коміту й не знайти дані -
afterCommit(); - дані в задачі: моделі серіалізуються як ідентифікатори (
SerializesModels) і перезавантажуються - стан на момент виконання може відрізнятися від стану на момент постановки; - моніторинг: невдалі задачі (
failed_jobs), довжина черги, час очікування - Horizon для Redis-черг; - деплой: воркери тримають старий код у пам'яті - після деплою
php artisan queue:restart.
Драйвери: database (просто, без додаткової інфраструктури), Redis (швидко, з Horizon), SQS та інші керовані сервіси.
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, зазвичай дорожче, ніж тримати моделі охайними за правилами вище.
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 дає змогу одному класу бути і контролером, і джобою, і командою - зручно, але додає «магії».
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 + кеш);
- модульний моноліт, де модуль не повинен віддавати свої моделі назовні.
Для співбесіди: важливо показати розуміння компромісу, а не «завжди» чи «ніколи».
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 вводять там, де потрібен контракт між частинами коду.
Стандартна структура 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 дають генератори й автозавантаження модулів, але суть - у дисципліні меж, а не в структурі папок.
Фасад - статичний «інтерфейс» до об'єкта з контейнера: 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 заповнив поле. Порядок стає неявним контрактом - його варто документувати чи перевіряти;
- мутація спільного об'єкта: кроки змінюють той самий об'єкт - важко зрозуміти, хто що змінив. Для складних процесів - незмінні об'єкти, які кожен крок повертає новими;
- надмірність: три рядки лінійного коду не потребують пайплайну з трьох класів;
- обробка помилок: виняток у середині зупиняє весь процес - має бути зрозуміло, що відбувається з уже зробленими змінами.
Проблема: всередині транзакції відправляються події чи ставляться задачі в чергу, але транзакція ще не закомічена.
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:
- в тій самій транзакції, що й зміна даних, записати повідомлення в таблицю
outbox; - окремий процес читає
outboxі відправляє повідомлення (в чергу, брокер, вебхук), позначаючи відправлені; - споживачі - ідемпотентні, бо повідомлення може прийти двічі.
Так зміна даних і факт «треба повідомити» атомарні - або обидва є, або жодного.
Інші правила:
- зовнішні виклики (HTTP, платежі) не робити всередині транзакції - транзакція тримає блокування весь час очікування відповіді, а відкотити зовнішню дію неможливо;
- транзакція - якомога коротша і лише навколо змін бази;
- тести:
RefreshDatabaseобгортає тест у транзакцію, тож «після коміту» в тестах поводиться особливо - 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: впровадження залежностей