Архітектура: питання на співбесіді рівня Junior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
35 питань
SRP - перша літера SOLID: у класу має бути одна причина для змін. Роберт Мартін формулює це так: модуль має відповідати перед одним «актором» - однією групою людей, чиї вимоги можуть його змінити.
Порушення:
class InvoiceService
{
public function create(Order $order): Invoice { /* розрахунок сум, податків */ }
public function renderPdf(Invoice $invoice): string { /* верстка */ }
public function send(Invoice $invoice): void { /* SMTP, шаблон листа */ }
}
У класу три причини змінюватися: бухгалтерія змінює правила податків, дизайнер - вигляд PDF, маркетинг - текст листа. Зміна одного може зламати інше, а тест розрахунку змушує налаштовувати пошту.
Після розділення:
class InvoiceCalculator { public function create(Order $order): Invoice {} }
class InvoicePdf { public function render(Invoice $invoice): string {} }
class InvoiceMailer { public function send(Invoice $invoice): void {} }
Як розпізнати порушення:
- назва класу з «And», «Manager», «Helper», «Util»;
- клас важко назвати одним іменником;
- методи класу використовують різні, непересічні групи властивостей;
- зміна в одній функції регулярно ламає тести іншої.
Пастка - надмірне дроблення. «Одна відповідальність» не означає «один метод». Десять класів по три рядки, які завжди змінюються разом, - теж погано: логіку однієї зміни доводиться збирати по багатьох файлах. Розділяють те, що змінюється з різних причин і в різний час.
Strategy - кілька взаємозамінних алгоритмів за спільним інтерфейсом. Код, що їх використовує, не знає, який саме алгоритм працює, і не містить розгалужень за типом.
interface ShippingCalculator
{
public function cost(Order $order): Money;
}
final class NovaPoshtaShipping implements ShippingCalculator { /* ... */ }
final class UkrposhtaShipping implements ShippingCalculator { /* ... */ }
final class PickupShipping implements ShippingCalculator { /* ... */ }
final class Checkout
{
public function __construct(private ShippingCalculator $shipping) {}
public function total(Order $order): Money
{
return $order->subtotal()->add($this->shipping->cost($order));
}
}
Що це дає:
- новий спосіб доставки - новий клас, без зміни
Checkout(принцип відкритості/закритості); - кожну стратегію легко протестувати окремо;
- немає
switch ($order->shipping_method), що розростається з кожним новим варіантом.
У Laravel цей патерн скрізь - під назвою «драйвери»:
- кеш:
file,redis,databaseза контрактомStore; - файлові диски: локальний, S3, FTP за спільним API
Storage; - черги, пошта, логи, сесії, хешування - той самий принцип.
Конкретна стратегія обирається конфігурацією (CACHE_STORE=redis), а код працює з контрактом.
Як обирати стратегію під час виконання: через контейнер (прив'язати інтерфейс до реалізації), фабрику чи мапу [метод => клас]. Якщо варіантів два й вони ніколи не множитимуться, простий if часто чесніший за патерн.
Принцип відкритості/закритості (Open/Closed Principle, «O» в SOLID): модуль має бути відкритим для розширення і закритим для змін. Нову поведінку додають новим кодом, а не переписуванням того, що вже працює й протестоване.
Порушення - кожна нова можливість змінює той самий код:
class ShippingCalculator
{
public function cost(Order $order, string $carrier): int
{
return match ($carrier) {
'nova_poshta' => 70 + $order->weight * 5,
'ukrposhta' => 45 + $order->weight * 3,
// новий перевізник - правка цього класу, ризик зламати наявних
};
}
}
Відповідно до OCP - розширення через новий клас:
interface ShippingMethod
{
public function cost(Order $order): int;
}
final class NovaPoshta implements ShippingMethod { /* ... */ }
final class Ukrposhta implements ShippingMethod { /* ... */ }
final class Meest implements ShippingMethod { /* ... */ } // новий - без змін в інших
class ShippingCalculator
{
public function cost(Order $order, ShippingMethod $method): int
{
return $method->cost($order);
}
}
Типові механізми розширення:
- поліморфізм через інтерфейси (Strategy);
- події: нова реакція на «замовлення оплачено» - новий слухач, а не правка контролера;
- декоратори й middleware - додати поведінку навколо наявної;
- конфігурація й реєстрація: нова команда, драйвер, канал сповіщень підключається в сервіс-провайдері.
Як це виглядає в Laravel: нові драйвери кешу, черг, файлових систем (Storage::extend()), канали сповіщень, правила валідації, макроси - фреймворк закритий для змін, але відкритий для розширення.
Застереження - не передбачати все наперед. OCP не означає «робити інтерфейс на кожен клас про всяк випадок». Розширювані точки варто вводити, коли з'являється друга реалізація чи явна потреба (правило трьох: втретє однакова зміна - час для абстракції). Передчасна гнучкість - це складність, яка може ніколи не знадобитися.
Корисний тест: чи доводилося останні кілька разів змінювати той самий match/switch, додаючи варіант? Якщо так - це місце, де OCP окупиться.
Принцип розділення інтерфейсів (Interface Segregation Principle, «I» в SOLID): клієнти не повинні залежати від методів, якими вони не користуються. Краще кілька вузьких інтерфейсів, ніж один «товстий».
Порушення:
interface Storage
{
public function read(string $path): string;
public function write(string $path, string $contents): void;
public function delete(string $path): void;
public function temporaryUrl(string $path, DateTimeInterface $expires): string;
public function listContents(string $dir): array;
}
Клас, якому потрібно лише читати файли, залежить від усього інтерфейсу. Реалізація для сховища, де немає тимчасових посилань, змушена кидати NotSupportedException - а це вже порушення й принципу Лісков.
Відповідно до ISP:
interface ReadsFiles
{
public function read(string $path): string;
}
interface WritesFiles
{
public function write(string $path, string $contents): void;
public function delete(string $path): void;
}
interface GeneratesTemporaryUrls
{
public function temporaryUrl(string $path, DateTimeInterface $expires): string;
}
final class ReportGenerator
{
public function __construct(private ReadsFiles $files) {} // лише те, що потрібно
}
Один клас може реалізовувати кілька інтерфейсів, а кожен споживач залежить лише від потрібного.
Навіщо:
- простіші тести: фейк для
ReadsFiles- один метод, а не п'ять заглушок; - менше зв'язності: зміна методу запису не зачіпає класи, що лише читають;
- чесні реалізації: немає методів, що кидають «не підтримується»;
- зрозумілі залежності: з конструктора видно, що саме клас використовує.
Приклади в PHP і Laravel:
- PHP:
Countable,IteratorAggregate,ArrayAccess,JsonSerializable,Stringable- маленькі інтерфейси, кожен про одну можливість; - Laravel:
ShouldQueue,ShouldBroadcast,HasLocalePreference,Arrayable,Jsonable,Responsable- вузькі контракти, які клас реалізує за потреби.
Межа здорового глузду: інтерфейс з одним методом на кожен метод класу - теж крайність. Групувати варто за тим, як інтерфейс використовують клієнти: методи, які завжди потрібні разом, - в одному інтерфейсі.
Observer (Спостерігач) - об'єкт-«видавець» повідомляє про зміну стану всіх зацікавлених «підписників», не знаючи про них нічого конкретного. Підписники реєструються самі.
Проблема, яку він розв'язує:
public function pay(Order $order): void
{
$order->markAsPaid();
$this->mailer->sendReceipt($order);
$this->warehouse->reserve($order);
$this->analytics->trackPurchase($order);
$this->crm->updateCustomer($order->customer);
// кожна нова реакція - правка цього методу
}
Оплата «знає» про пошту, склад, аналітику й CRM. Зміни в будь-якій з цих систем зачіпають код оплати.
З Observer (у Laravel - події й слухачі):
public function pay(Order $order): void
{
$order->markAsPaid();
OrderPaid::dispatch($order);
}
class SendReceipt { public function handle(OrderPaid $event): void { /* ... */ } }
class ReserveStock { public function handle(OrderPaid $event): void { /* ... */ } }
Laravel знаходить слухачів автоматично за типом події в методі handle. Нова реакція - новий клас-слухач, код оплати не змінюється.
Інші реалізації Observer у Laravel:
- спостерігачі моделей (
#[ObservedBy(OrderObserver::class)]) - реакція наcreated,updated,deleted; - події моделей (
static::created(...)); - трансляція подій у браузер (
ShouldBroadcast) - підписники навіть в іншому процесі; Livewire#[On]і події JavaScript - та сама ідея на клієнті.
Переваги: слабка зв'язність, легко додавати реакції, слухачі можна виконувати в черзі (ShouldQueue).
Недоліки й пастки:
- неявний потік: з коду
OrderPaid::dispatch()не видно, що станеться далі - треба шукати слухачів (php artisan event:list); - порядок виконання слухачів не варто вважати гарантованим для бізнес-логіки;
- спостерігачі моделей спрацьовують на кожне збереження, включно з сидерами, імпортом, тестами - і не спрацьовують на масові запити (
Order::where(...)->update()); - транзакції: подія, оброблена до коміту, може відправити лист про замовлення, яке потім відкотиться -
ShouldDispatchAfterCommitчиafterCommitдля слухачів у черзі.
Коли не варто: якщо дія - обов'язкова частина сценарію (без неї операція неуспішна), її краще викликати явно, а не ховати в слухачі.
Code smell («запах коду») - ознака, що в коді, ймовірно, є проблема дизайну. Сам по собі не баг - код працює, - але його важко читати, змінювати й тестувати.
Найпоширеніші:
- Довгий метод. Метод на 100+ рядків, який робить кілька речей. Лікується виділенням методів з іменами, що пояснюють намір.
- Великий клас («God object»): тисячі рядків, десятки залежностей. Розділити за відповідальностями.
- Дублювання: однакова логіка в кількох місцях - зміну доведеться робити скрізь, і одне місце точно забудуть.
- Довгий список параметрів:
createUser($name, $email, $phone, $role, $team, $sendEmail, $isAdmin). Об'єкт-параметр (DTO) чи кілька методів. - Прапорець-параметр:
render(true)- незрозуміло без заглядання всередину. Два методи чи іменований аргумент. - Одержимість примітивами: гроші як
float, email якstring, статус як магічний рядок. Value objects і enum. - Заздрість до функцій (feature envy): метод постійно звертається до даних іншого класу - можливо, він має жити там.
- Розгалуження за типом: однаковий
switch ($type)у кількох місцях - кандидат на поліморфізм. - Магічні числа:
if ($status === 3),sleep(86400). - Коментар, що пояснює заплутаний код, - часто краще переписати код так, щоб пояснення стало зайвим.
Як з ними працювати: запах - привід придивитися, а не вимога негайно переписувати. Рефакторять тоді, коли код доводиться змінювати: «правило бойскаута» - залишити код трохи кращим, ніж знайшли.
Частину запахів знаходять інструменти: PHPStan/Larastan, PHP Mess Detector, метрики складності в IDE.
Рефакторинг - зміна структури коду без зміни поведінки. Без тестів неможливо переконатися, що поведінка справді не змінилася. Тому перший крок - створити страховку.
1. Тести-характеристики (characterization tests). Не «як має працювати», а «як працює зараз», включно з дивацтвами. Викликати код з різними входами, записати результати й зафіксувати їх у тестах.
it('рахує знижку так само, як до рефакторингу', function (array $order, int $expected) {
expect(DiscountCalculator::calculate($order))->toBe($expected);
})->with([
[['total' => 1000, 'vip' => false], 0],
[['total' => 1000, 'vip' => true], 100],
[['total' => 5000, 'vip' => false], 250],
]);
2. Тести на верхньому рівні, якщо код важко тестувати окремо: feature-тест HTTP-ендпоінту, що перевіряє відповідь і зміни в базі. Грубо, але надійно.
3. Маленькі кроки. Перейменувати змінну, виділити метод, перенести метод - по одній зміні, з прогоном тестів після кожної. Невдалий крок легко відкотити.
4. Автоматичні рефакторинги IDE та Rector. «Rename», «Extract Method», «Move» у PhpStorm змінюють усі посилання коректніше, ніж ручні правки. Rector автоматизує масові зміни.
5. Окремо рефакторинг, окремо нові функції. Не змішувати в одному коміті: якщо щось зламалося, видно, що саме.
6. Статичний аналіз (PHPStan/Larastan) ловить зламані виклики й типи, яких тести можуть не покрити.
Чого не робити: «великого переписування» без тестів - найризикованішого сценарію. Краще поступово: нова структура поруч зі старою, поступове перенесення (патерн «Strangler Fig»), старий код видаляється, коли на нього вже ніхто не посилається.
Рефакторинг - зміна внутрішньої структури коду без зміни його поведінки. «Виділити метод» (Extract Method / Extract Function) - найчастіший з них: фрагмент коду переноситься в окремий метод з назвою, що пояснює навіщо він.
До:
public function checkout(Cart $cart, User $user): Order
{
// перевіряємо, що всі товари в наявності
foreach ($cart->items as $item) {
if ($item->product->stock < $item->qty) {
throw new OutOfStock($item->product);
}
}
// рахуємо суму зі знижкою
$total = $cart->items->sum(fn ($i) => $i->product->price * $i->qty);
if ($user->isVip()) {
$total = (int) round($total * 0.9);
}
return Order::create(['user_id' => $user->id, 'total' => $total]);
}
Після:
public function checkout(Cart $cart, User $user): Order
{
$this->ensureInStock($cart);
return Order::create(['user_id' => $user->id, 'total' => $this->totalFor($cart, $user)]);
}
private function ensureInStock(Cart $cart): void { /* ... */ }
private function totalFor(Cart $cart, User $user): int { /* ... */ }
Коли виділяти:
- коментар пояснює, що робить блок коду - назва методу може замінити коментар;
- метод не вміщується на екран чи має кілька рівнів вкладеності;
- той самий фрагмент повторюється в кількох місцях;
- змішано рівні абстракції: бізнес-кроки поруч із деталями (цикли, форматування, SQL);
- блок потрібно тестувати окремо.
Як робити безпечно:
- переконатися, що є тести (або написати їх для поточної поведінки);
- виділити метод - автоматично в IDE (PhpStorm: Extract Method), щоб не помилитися з параметрами й поверненим значенням;
- дати назву за наміром («чи в наявності», «сума зі знижкою»), а не за реалізацією («цикл по товарах»);
- запустити тести.
Коли виділення не допомагає: якщо виділеному фрагменту потрібно передати шість параметрів і повернути три значення - це ознака, що код хоче стати окремим класом (Extract Class), а не методом.
Зворотний рефакторинг - Inline Method - коли метод-обгортка нічого не пояснює і лише ускладнює читання.
Докладніше в документації: Каталог рефакторингів: Extract Function
Код читають набагато частіше, ніж пишуть. Перейменування - найдешевший рефакторинг з найбільшим ефектом на читабельність: назва, що точно описує сутність, позбавляє потреби читати реалізацію.
Ознаки поганих назв:
$data = $this->get($id); // які дані? що «отримати»?
$flag = true; // який прапорець?
$tmp, $res, $arr, $obj; // назви за типом, а не за змістом
function process($x) {} // «обробити» - що саме?
$d = 30; // 30 чого? днів? хвилин?
Як краще:
$invoice = $this->findInvoice($invoiceId);
$isRegisteredForDiscounts = true;
function sendOverdueReminders(Collection $invoices): void {}
$gracePeriodInDays = 30;
Правила, що допомагають:
- за наміром, а не за реалізацією:
activeSubscribers(), а неgetUsersWhereStatusIsOneAndPaidIsTrue(); - булеві значення - питанням:
isPaid,hasAccess,canPublish,shouldRetry; - методи - дієсловом:
calculateTotal(),sendInvoice(); класи й змінні - іменником; - одиниці виміру в назві, якщо тип їх не передає:
timeoutSeconds,priceCents,sizeInBytes; - мова предметної області: якщо бізнес каже «замовлення», «відвантаження», «повернення» - у коді
Order,Shipment,Refund, а неItem,Process,Operation; - послідовність: не
fetch/get/retrieve/loadдля того самого типу дії в різних місцях; - довжина пропорційна області видимості:
$iу короткому циклі - нормально; змінна, що живе в усьому класі, - описова назва.
Перейменування безпечне з інструментами: IDE (Rename у PhpStorm) змінює всі використання, включно з рядковими посиланнями й документацією; статичний аналіз (PHPStan) і тести ловлять пропущене.
Пастки:
- публічні API (маршрути, поля JSON, назви колонок, події) - перейменування ламає клієнтів і потребує міграції чи періоду сумісності;
- рядкові посилання (
config('services.old_name'), назви в Blade,$model->getAttribute('field')) IDE може не знайти; - магічні методи й динамічні виклики - перевіряти тестами.
Корисне правило: якщо назву важко придумати, часто проблема не в назві, а в коді - метод чи клас робить забагато різного.
Докладніше в документації: Каталог рефакторингів: Rename Variable
DRY (Don't Repeat Yourself) - кожне знання в системі має мати одне джерело. Дублювання шкодить, бо зміну доводиться вносити в кількох місцях, і рано чи пізно одне з них пропускають.
Але не всяке схоже код - дублювання знання. Два фрагменти можуть виглядати однаково випадково, а змінюватися з різних причин:
// валідація реєстрації
'name' => ['required', 'string', 'max:255'],
// валідація назви товару
'name' => ['required', 'string', 'max:255'],
Це не одне знання: правила для імені користувача й назви товару завтра розійдуться. Спільна функція nameRules() їх штучно зв'яже.
«Неправильна абстракція» (Сенді Метц) - типовий сценарій:
- програміст бачить повтор і виносить код у спільну функцію чи базовий клас;
- з'являється новий випадок, що «майже підходить» - додають параметр;
- ще один випадок - ще параметр, умова всередині;
- через рік абстракція має прапорці
$isAdmin,$skipValidation,$legacyMode, і ніхто не розуміє, як вона працює, а змінювати страшно, бо вона використовується скрізь.
Висновок Метц: «Дублювання значно дешевше за неправильну абстракцію». Якщо абстракція обросла параметрами й умовами, вигідніше повернути код назад (вбудувати в місця використання) і заново подивитися, що справді спільне.
Практичні правила:
- правило трьох: перший раз - пишемо, другий - терпимо дублювання, третій - виносимо абстракцію. До третього разу видно, що справді спільне;
- дублюється знання чи текст? Якщо обидва місця змінюватимуться разом з тієї самої причини - це знання, його варто об'єднати;
- ознаки поганої абстракції: булеві параметри, що вмикають гілки;
if ($type === ...)усередині «спільного» коду; коментарі «для X не викликати»; - дублювання в тестах часто корисне: тест, який читається сам по собі без переходів у допоміжні функції, - кращий тест.
WET («Write Everything Twice») і AHA («Avoid Hasty Abstractions») - жартівливі назви того самого принципу: не поспішати з абстракціями, доки не стане зрозуміло, яка вона має бути.
Докладніше в документації: Сенді Метц: The Wrong Abstraction
Товстий контролер - метод контролера на сотню рядків, де змішано все: валідацію, перевірку прав, бізнес-правила, роботу з базою, відправку листів, виклики сторонніх 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, зазвичай дорожче, ніж тримати моделі охайними за правилами вище.
Питання рівня Junior з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 7 відкритих вакансій рівня Junior. Переглянути вакансії