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()) у контролері - цілком нормально. Окремий клас на кожен рядок коду - теж складність. Виносити варто, коли з'являється реальна логіка або потреба викликати ту саму дію з кількох місць.
Сервіс-контейнер - механізм, що створює об'єкти й автоматично передає їм залежності. Клас оголошує, що йому потрібно, у конструкторі - контейнер підставляє.
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, зазвичай дорожче, ніж тримати моделі охайними за правилами вище.