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

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

Докладніше в документації: 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: впровадження залежностей