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

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

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

5 питань

Товстий контролер - метод контролера на сотню рядків, де змішано все: валідацію, перевірку прав, бізнес-правила, роботу з базою, відправку листів, виклики сторонніх 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