Senior: питання на співбесіді з теми «Архітектура Laravel-застосунку»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
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: впровадження залежностей