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

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

Пастка - надмірне дроблення. «Одна відповідальність» не означає «один метод». Десять класів по три рядки, які завжди змінюються разом, - теж погано: логіку однієї зміни доводиться збирати по багатьох файлах. Розділяють те, що змінюється з різних причин і в різний час.

Докладніше в документації: Single-responsibility principle

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 часто чесніший за патерн.

Докладніше в документації: Strategy

Принцип відкритості/закритості (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 для слухачів у черзі.

Коли не варто: якщо дія - обов'язкова частина сценарію (без неї операція неуспішна), її краще викликати явно, а не ховати в слухачі.

Докладніше в документації: Refactoring.Guru: Observer

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.

Докладніше в документації: Code smells

Рефакторинг - зміна структури коду без зміни поведінки. Без тестів неможливо переконатися, що поведінка справді не змінилася. Тому перший крок - створити страховку.

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);
  • блок потрібно тестувати окремо.

Як робити безпечно:

  1. переконатися, що є тести (або написати їх для поточної поведінки);
  2. виділити метод - автоматично в IDE (PhpStorm: Extract Method), щоб не помилитися з параметрами й поверненим значенням;
  3. дати назву за наміром («чи в наявності», «сума зі знижкою»), а не за реалізацією («цикл по товарах»);
  4. запустити тести.

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

«Неправильна абстракція» (Сенді Метц) - типовий сценарій:

  1. програміст бачить повтор і виносить код у спільну функцію чи базовий клас;
  2. з'являється новий випадок, що «майже підходить» - додають параметр;
  3. ще один випадок - ще параметр, умова всередині;
  4. через рік абстракція має прапорці $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()) у контролері - цілком нормально. Окремий клас на кожен рядок коду - теж складність. Виносити варто, коли з'являється реальна логіка або потреба викликати ту саму дію з кількох місць.

Докладніше в документації: 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

Питання рівня Junior з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 7 відкритих вакансій рівня Junior. Переглянути вакансії