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