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

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

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

5 питань

Action-клас - клас, що виконує одну бізнес-операцію і має один публічний метод (handle() чи __invoke()):

final class PlaceOrder
{
    public function __construct(
        private InventoryService $inventory,
        private PaymentGateway $payments,
    ) {}

    public function handle(User $user, array $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $order = $user->orders()->create([...]);
            $this->inventory->reserve($order);
            $this->payments->authorize($order);
            OrderPlaced::dispatch($order);

            return $order;
        });
    }
}

Сервіс - клас, що групує кілька операцій навколо однієї області: OrderService з place(), cancel(), refund(), ship().

Переваги action-класів:

  • назва = бізнес-операція: PlaceOrder, CancelSubscription, InviteTeamMember - структура папки app/Actions читається як перелік того, що вміє застосунок;
  • маленькі й сфокусовані - не розростаються до «сервісу на 2000 рядків», який з часом обростає всім, що стосується замовлень;
  • точні залежності: кожен action впроваджує лише те, що йому потрібно;
  • виклик звідусіль: контролер, команда Artisan, джоба, Livewire-компонент, тест - та сама дія;
  • легко тестувати: одна операція - один набір тестів.

Коли сервіс доречніший:

  • спільний стан чи конфігурація для групи пов'язаних операцій (клієнт стороннього API з методами для різних ендпойнтів);
  • обгортка над інфраструктурою: ExchangeRateService, GeoIpService - не бізнес-операції, а технічні можливості;
  • кілька дрібних операцій, які окремими класами створили б шум.

Практики, що добре працюють разом:

  • action приймає перевірені дані (масив з validated() чи DTO), а не Request - щоб працювати поза HTTP;
  • транзакція - межа action-а;
  • побічні реакції - через події, а не прямими викликами в action;
  • action може викликати інші actions, але глибокі ланцюжки - сигнал, що межі обрано невдало.

Цей підхід використовують офіційні стартові набори й Fortify (app/Actions/Fortify/CreateNewUser). Пакет lorisleiva/laravel-actions дає змогу одному класу бути і контролером, і джобою, і командою - зручно, але додає «магії».

Докладніше в документації: Laravel: контролери однієї дії

Repository (за Фаулером) - посередник між доменом і даними, що поводиться як колекція доменних об'єктів: додати, знайти, видалити, - приховуючи, як і де вони зберігаються.

Типова реалізація в Laravel-проєктах:

interface OrderRepository
{
    public function find(int $id): ?Order;
    public function forUser(User $user): Collection;
    public function save(Order $order): void;
}

final class EloquentOrderRepository implements OrderRepository { /* ... */ }

Аргументи «за»:

  • запити в одному місці: складні запити не розкидані по контролерах і сервісах;
  • підміна в тестах: бізнес-логіку можна тестувати з репозиторієм у пам'яті, без бази;
  • незалежність від сховища: теоретично можна замінити Eloquent чи базу.

Аргументи «проти» в контексті Laravel:

  • Eloquent - уже Active Record: модель сама вміє зберігатися й будувати запити. Репозиторій, що повертає ті самі моделі Eloquent, не приховує сховища: модель усе одно має save(), delete() і ледаче завантаження зв'язків;
  • «дірява» абстракція: методи на кшталт findByStatusAndDateAndUserWithRelations() множаться, бо Eloquent-будівник запитів гнучкіший за будь-який інтерфейс;
  • заміна бази - рідкісний сценарій, а заміна ORM у Laravel-проєкті - ще рідший;
  • тестування з базою в Laravel дешеве (SQLite в пам'яті, транзакції, фабрики), тож аргумент «тести без бази» слабшає;
  • більше коду: інтерфейс + реалізація + реєстрація в контейнері на кожну модель.

Компромісні рішення, що дають більшу частину користі:

  • scopes і власні будівники запитів (#[UseEloquentBuilder]) - повторювані умови в одному місці;
  • query-класи для складних запитів і звітів (MonthlyRevenueQuery);
  • action-класи - бізнес-операції, що працюють з моделями напряму.

Коли репозиторій справді виправданий:

  • доменна модель окремо від Eloquent (DDD з чистими сутностями, які не знають про базу) - тоді репозиторій обов'язковий: він перетворює рядки бази на доменні об'єкти;
  • дані з кількох джерел за одним інтерфейсом (база + зовнішній API + кеш);
  • модульний моноліт, де модуль не повинен віддавати свої моделі назовні.

Для співбесіди: важливо показати розуміння компромісу, а не «завжди» чи «ніколи».

Докладніше в документації: Мартін Фаулер: Repository

DTO (Data Transfer Object) - простий об'єкт для передачі даних між шарами застосунку: типізовані поля без бізнес-поведінки.

Проблема масивів:

public function handle(array $data): Order
{
    $data['customer_id'];   // а чи є такий ключ? який тип? як він називається - customerId?
}

Масив не має контракту: невідомо, які ключі обов'язкові, яких типів, - доводиться шукати, звідки він прийшов. Помилки в назвах ключів знаходяться лише під час виконання.

DTO на сучасному PHP:

final readonly class PlaceOrderData
{
    /** @param list<OrderLineData> $lines */
    public function __construct(
        public int $customerId,
        public array $lines,
        public ?string $comment = null,
        public DeliveryMethod $delivery = DeliveryMethod::Courier,
    ) {}

    public static function fromRequest(StoreOrderRequest $request): self
    {
        return new self(
            customerId: $request->user()->id,
            lines: array_map(OrderLineData::fromArray(...), $request->validated('items')),
            comment: $request->validated('comment'),
            delivery: DeliveryMethod::from($request->validated('delivery')),
        );
    }
}

Що дає DTO:

  • явний контракт: з сигнатури handle(PlaceOrderData $data) видно, що потрібно операції;
  • типи й підказки IDE, перевірка статичним аналізом;
  • незмінність (readonly): дані не зміняться непомітно по дорозі;
  • незалежність від джерела: та сама дія викликається з HTTP (fromRequest), команди Artisan, імпорту CSV, тестів;
  • енуми й об'єкти-значення замість рядків.

Де DTO корисні найбільше:

  • вхід бізнес-операцій (action-класи, сервіси);
  • відповіді сторонніх API - перетворити JSON на типізовані об'єкти одразу на межі, а не тягнути масиви по коду;
  • межі модулів - модуль віддає DTO, а не свої моделі Eloquent.

Інструменти:

  • readonly-класи PHP 8.2+ з властивостями конструктора - достатньо для більшості випадків;
  • spatie/laravel-data - DTO з вбудованою валідацією, перетворенням з/у масив, Request, моделі, генерацією TypeScript-типів;
  • Form Request + метод toDto().

Чого уникати: DTO на кожну модель «про всяк випадок», які дублюють поля моделі один в один, - це шар без користі. DTO вводять там, де потрібен контракт між частинами коду.

Докладніше в документації: PHP: readonly-класи

Стандартна структура Laravel групує код за технічним типом: усі контролери в Http/Controllers, усі моделі в Models. У великому застосунку зміна однієї функції (наприклад, оплати) зачіпає десяток папок, а межі між частинами системи не видно.

Модульний моноліт групує код за предметними областями, лишаючись одним застосунком з одним деплоєм:

app/
  Modules/
    Billing/
      Actions/ Models/ Http/ Events/ Listeners/ Jobs/
      BillingServiceProvider.php
      routes.php
    Catalog/
    Orders/
    Shared/        ← спільні об'єкти-значення, базові класи

Кожен модуль:

  • має власний сервіс-провайдер, що реєструє маршрути, слухачі, прив'язки в контейнері, міграції (loadMigrationsFrom), конфігурацію;
  • володіє своїми таблицями - інші модулі не пишуть у них напряму;
  • має публічний інтерфейс - action-класи, контракти, події, DTO, - а все інше вважається внутрішнім.

Як модулі взаємодіють:

  • виклик публічного контракту: Orders викликає Billing\Contracts\ChargesCustomers, а не лізе в моделі Billing;
  • події: Orders публікує OrderPlaced, Billing і Inventory підписуються - без прямої залежності;
  • ідентифікатори замість моделей на межах: модуль передає customerId, а не модель Customer з іншого модуля.

Як утримати межі - найскладніша частина:

  • архітектурні тести (Pest arch()): «код з Modules\Catalog не використовує Modules\Billing\Models»;
  • зв'язки Eloquent між модулями - найчастіший спосіб непомітно зламати межі. Їх обмежують чи замінюють запитами через публічний інтерфейс модуля;
  • рев'ю коду з увагою до нових залежностей між модулями.

Переваги перед мікросервісами: один деплой, одна база (з логічним розділенням), транзакції без розподілених протоколів, простий рефакторинг. А модуль з чіткими межами можна винести в окремий сервіс пізніше - коли з'явиться реальна причина.

Коли це потрібно: великий застосунок, кілька команд, складна предметна область. Для невеликого проєкту стандартна структура Laravel простіша і зрозуміліша будь-якому новому розробнику.

Інструменти: пакети на кшталт nwidart/laravel-modules чи internachi/modular дають генератори й автозавантаження модулів, але суть - у дисципліні меж, а не в структурі папок.

Докладніше в документації: Laravel: сервіс-провайдери

Фасад - статичний «інтерфейс» до об'єкта з контейнера: Cache::get(), Mail::to(), Http::get(). За кулісами Cache звертається до контейнера й викликає метод справжнього об'єкта.

// фасад
final class ExchangeRates
{
    public function rate(string $currency): float
    {
        return Cache::remember("rate:$currency", 3600, fn () => Http::get(...)->json('rate'));
    }
}

// впровадження залежностей
final class ExchangeRates
{
    public function __construct(
        private CacheRepository $cache,
        private HttpFactory $http,
    ) {}
}

Тестування - обидва способи працюють. Попри статичний синтаксис, фасади Laravel підмінюються:

Cache::shouldReceive('remember')->once()->andReturn(41.5);
Http::fake(['bank.example/*' => Http::response(['rate' => 41.5])]);
Mail::fake();
Queue::fake();

Це головна відмінність від справжніх статичних класів: фасад - не глобальний стан, а точка доступу до контейнера.

Аргументи на користь впровадження залежностей:

  • явні залежності: з конструктора видно, з чим працює клас. Клас з десятком фасадів у методах може приховувати десяток залежностей;
  • «запах» розростання: конструктор з вісьмома параметрами одразу показує, що клас робить забагато; фасади ховають цю проблему;
  • незалежність від фреймворку: клас з інтерфейсами в конструкторі можна використати поза Laravel (бібліотека, пакет);
  • статичний аналіз і IDE краще розуміють впроваджені залежності.

Аргументи на користь фасадів:

  • лаконічність - особливо в контролерах, маршрутах, Blade, коді, що не перевикористовується;
  • готові фейки (Mail::fake(), Storage::fake(), Bus::fake()) з виразними перевірками;
  • ідіоматичність: документація й більшість Laravel-коду використовують фасади.

Практичний підхід, поширений у командах:

  • фасади - у «краях» застосунку: контролери, маршрути, команди, конфігурація, тести;
  • впровадження через конструктор - у класах бізнес-логіки (actions, сервіси, домен), де важлива явність залежностей;
  • не змішувати обидва підходи в одному класі без причини.

Real-time фасади (use Facades\App\Services\Rates;) дають статичний доступ до будь-якого класу - зручно, але ще більше ховають залежності.

Докладніше в документації: Laravel: фасади чи впровадження залежностей