Питання на співбесіді: Архітектура
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
19 питань
MVC (Model-View-Controller) - це архітектурний патерн, який розділяє застосунок на три шари, аби логіка обробки даних, бізнес-правила та відображення не змішувались між собою. Laravel побудований навколо MVC, тож кожному шару відповідає своя директорія.
Три складові:
- Model - відповідає за дані та бізнес-логіку: спілкування з базою, зв'язки, валідаційні правила домену. У Laravel моделі розширюють Eloquent і лежать у
app/Models. - View - відповідає лише за відображення. У Laravel це Blade-шаблони у
resources/views; вони не повинні містити складної логіки. - Controller - приймає HTTP-запит, координує роботу моделей, готує дані й повертає View або відповідь. Лежать у
app/Http/Controllers.
Як це працює разом: запит потрапляє на маршрут (routes/web.php), той викликає метод контролера. Контролер звертається до моделі за даними й передає їх у view:
class PostController extends Controller
{
// Route Model Binding: $post резолвиться автоматично
public function show(Post $post)
{
// контролер координує: бере дані з моделі й віддає у view
return view('posts.show', ['post' => $post]);
}
}
Навіщо: розділення відповідальностей спрощує тестування, підтримку й командну роботу - верстку можна змінити, не чіпаючи запити до БД, і навпаки. Laravel розширює класичний MVC сервісним контейнером, middleware та провайдерами.
Фасади надають зручний статичний інтерфейс до об'єктів із Service Container.
Cache::put('key', 'value', 60);
Route::get('/', fn () => view('home'));
Попри статичний синтаксис, це не справжні статичні методи: фасад через __callStatic() дістає реальний об'єкт із контейнера й викликає метод уже на ньому. Тому фасади тестовані - їх можна мокати:
Cache::shouldReceive('get')->once()->andReturn('value');
Кожен фасад має «accessor» - рядковий ключ сервісу в контейнері.
Контролер - це шар HTTP: він приймає запит, дістає з нього дані, викликає бізнес-логіку й формує відповідь. Сервіс - це сама бізнес-логіка, яка про HTTP нічого не знає.
Як виглядає змішування:
public function store(Request $request)
{
$order = Order::create($request->validated());
$order->items()->createMany($request->input('items'));
$total = collect($request->input('items'))->sum(fn ($i) => $i['price'] * $i['qty']);
$order->update(['total' => $total]);
Mail::to($order->email)->send(new OrderPlaced($order));
Http::post('https://warehouse.example/orders', $order->toArray());
return redirect()->route('orders.show', $order);
}
Тут порахунок суми, лист і виклик складу існують лише всередині HTTP-запиту.
Те саме з сервісом:
class PlaceOrder
{
public function handle(array $data): Order
{
return DB::transaction(function () use ($data) {
$order = Order::create($data);
$order->items()->createMany($data['items']);
$order->update(['total' => $this->total($data['items'])]);
OrderPlaced::dispatch($order);
return $order;
});
}
}
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
$order = $placeOrder->handle($request->validated());
return redirect()->route('orders.show', $order);
}
Що це дає:
- Ту саму логіку можна викликати з Artisan-команди, черги чи імпорту - не тільки з форми.
- Тестувати можна без HTTP-запиту: створили масив, викликали
handle(), перевірили результат. - Транзакція охоплює всю операцію цілком.
- Контролер знову читається за кілька секунд.
Не поспішайте. Для Post::create($request->validated()) окремий сервіс - зайвий шар. Виносити варто тоді, коли з'являється щось із трьох: кілька моделей в одній операції, потреба викликати логіку не з HTTP, або контролер перестав вміщатися на екран.
Repository - патерн, що ховає доступ до даних за інтерфейсом: код працює з PostRepositoryInterface, не знаючи, звідки беруться записи.
interface PostRepositoryInterface
{
public function published(): Collection;
}
class EloquentPostRepository implements PostRepositoryInterface
{
public function published(): Collection
{
return Post::where('is_published', true)->get();
}
}
Питання зі співбесіди зазвичай про те, чи це потрібно тут. Аргумент «щоб замінити ORM» на практиці не спрацьовує: Eloquent - це Active Record, його моделі пронизують увесь застосунок, і підміна сховища все одно означала б переписування. Заміни ORM у живому проєкті майже не трапляється.
Аргумент «щоб тестувати» теж слабкий у Laravel: є RefreshDatabase і фабрики, тож тест із реальною базою пишеться просто й перевіряє більше, ніж мок репозиторію.
Що при цьому втрачається: зникають скопи, ліниві зв'язки й with() у місці виклику - або репозиторій обростає методами на кожну комбінацію фільтрів, або починає повертати Builder, і абстракція протікає.
Коли Repository справді доречний:
- Дані живуть не в Eloquent - зовнішнє API, Elasticsearch, кілька джерел за одним інтерфейсом.
- Домен свідомо відокремлений від фреймворку (DDD), і моделі домену не є Eloquent-моделями.
Що зазвичай працює краще: локальні скопи для повторюваних вибірок і Action/Service-класи для операцій. Вони дають ту саму зібраність без прошарку, який дублює Eloquent.
«Тонкий контролер» - це той, що лише координує: приймає запит, викликає потрібне й повертає відповідь. Усе інше живе деінде.
Що виносять із контролера і куди:
| Що | Куди |
|---|---|
| Правила валідації | Form Request |
| Перевірка прав | Policy або authorize() у Form Request |
| Бізнес-логіка | Service чи Action-клас |
| Складна вибірка | Локальний скоп на моделі |
| Формат відповіді | API Resource |
| Побічні ефекти (лист, індексація) | Слухач події або джоба в черзі |
Було:
public function store(Request $request)
{
$data = $request->validate([...]); // валідація
if (! $request->user()->can('create', Post::class)) { // права
abort(403);
}
$post = Post::create($data); // логіка
$post->tags()->sync($data['tags']);
Mail::to($post->author)->send(new PostCreated($post)); // побічний ефект
return response()->json([ // формат
'id' => $post->id,
'title' => $post->title,
]);
}
Стало:
public function store(StorePostRequest $request, CreatePost $createPost)
{
return new PostResource($createPost->handle($request->validated()));
}
Валідація й права спрацювали до входу в метод, логіка з побічними ефектами - усередині CreatePost, формат - у ресурсі.
Навіщо це насправді. Не заради краси: логіку з контролера не викликати ні з Artisan-команди, ні з черги, ні з тесту без HTTP-запиту. Щойно та сама операція знадобилась у другому місці, тонкий контролер перестає бути питанням смаку.
Де межа. Post::create($request->validated()) не потребує ні сервісу, ні ресурсу - шар заради шару лише додає файлів. Виносьте, коли з'явилася друга причина: кілька моделей в одній операції, виклик не з HTTP або контролер перестав вміщатися на екран.
У запит форми (Form Request) і політику - тоді контролер отримує вже перевірені дані.
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'max:50'],
'items.*.product_id' => ['required', 'exists:products,id'],
];
}
}
class OrderController
{
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
$order = $placeOrder($request->user(), $request->validated());
return redirect()->route('orders.show', $order);
}
}
Розподіл відповідальності:
- Form Request - формат вхідних даних і, за потреби, доступ до дії;
- Політика - правила доступу до моделі (
update,delete), щоб їх перевикористовували контролери, Livewire, API й Blade (@can); - Дія чи сервіс - бізнес-логіка, яка не знає про HTTP;
- Контролер - лише склеює: приймає запит, викликає дію, повертає відповідь.
Перевага не лише в чистоті: запит форми й політику тестують окремо, а ту саму дію викликають з команди чи черги без підробки HTTP-запиту.
Ресурсний контролер описує CRUD над однією сутністю: index, show, store, update, destroy. Але не кожна дія вкладається в ці сім методів.
class PublishPostController
{
public function __invoke(Post $post, PublishPost $publish): RedirectResponse
{
Gate::authorize('publish', $post);
$publish($post);
return back()->with('status', 'Опубліковано');
}
}
Route::post('/posts/{post}/publish', PublishPostController::class);
Коли __invoke доречний:
- дія не є CRUD: «опублікувати», «скасувати замовлення», «експортувати звіт»;
- у дії свої залежності, які не потрібні іншим методам;
- ресурсний контролер розрісся до десятка нестандартних методів.
Коли ресурсний: стандартні операції над сутністю - так маршрути й імена передбачувані (posts.update), а Route::resource() реєструє їх одним рядком.
Типова еволюція: PostController з методами publish, unpublish, duplicate, export розбивають на ресурсний CRUD і кілька контролерів-дій. Кожен файл короткий, назва каже, що він робить, а залежності не змішуються.
Контролер-дія не замінює клас-дію: перший - про HTTP, другий - про бізнес-логіку, яку можна викликати звідусіль.
Шлях запиту:
public/index.php- єдина точка входу; підключає автозавантажувач Composer.- Створюється екземпляр застосунку (Service Container) із
bootstrap/app.php. - HTTP Kernel обробляє запит, завантажує Service Providers (
register→boot). - Запит проходить глобальні middleware (наприклад, обробка сесій, CSRF).
- Router зіставляє URL із маршрутом, виконуються middleware маршруту.
- Викликається контролер/замикання, формується Response.
- Відповідь проходить middleware у зворотному порядку й повертається клієнту; виконується
terminate().
Ключова ідея: контейнер і провайдери бутстрапять застосунок, а middleware утворюють «цибулю» навколо обробки запиту.
- CQRS (Command Query Responsibility Segregation) розділяє запис (Commands, що змінюють стан) і читання (Queries). Read-модель можна оптимізувати окремо (денормалізовані проєкції, окрема БД).
- Event Sourcing зберігає не поточний стан, а послідовність подій; поточний стан відновлюється їх відтворенням. Дає повний аудит і «подорож у часі».
// концептуально
$aggregate->retrieve($uuid)
->placeOrder($data) // emit OrderPlaced
->persist(); // зберегти подію
У Laravel зазвичай через пакет spatie/laravel-event-sourcing (aggregates, projectors, reactors). Застосовувати варто там, де критичні аудит і складна доменна логіка - це додає суттєву складність, тож не для типового CRUD.
Два основні підходи:
Single Database (shared schema) - усі орендарі в одній БД, розділення за tenant_id у кожній таблиці. Ізоляція забезпечується global scope, що автоматично додає where tenant_id = ?.
- Плюси: просто й дешево. Мінуси: ризик витоку даних при помилці у scope.
Multi Database - окрема БД (або схема) на орендаря, динамічне перемикання з'єднання за поточним tenant.
- Плюси: сильна ізоляція, легше бекапити/масштабувати окремого клієнта. Мінуси: складніші міграції (на кожну БД).
Tenancy::initialize($tenant); // перемкнути конфіг з'єднання/кеш/файли
Популярний пакет - stancl/tenancy. Вибір залежить від вимог до ізоляції та масштабу.
Гексагональна архітектура ізолює ядро бізнес-логіки від зовнішнього світу.
- Ports - інтерфейси, через які ядро спілкується зі світом (
PaymentGateway,UserRepository). - Adapters - конкретні реалізації портів (
StripeAdapter,EloquentUserRepository, HTTP-контролер).
[ HTTP / CLI / Queue ] → Port → [ Domain Core ] → Port → [ DB / API / Mail ]
(adapters) (бізнес-логіка) (adapters)
Ядро не знає про Laravel, БД чи HTTP - воно залежить лише від абстракцій. Перевага: домен тестується ізольовано, зовнішні залежності легко підмінювати. У Laravel порти біндять до адаптерів через Service Container.
DDD фокусується на моделюванні бізнес-домену спільною мовою з експертами. Ключові поняття: Entities, Value Objects, Aggregates, Domain Events, Bounded Contexts.
У Laravel це зазвичай означає відхід від стандартної структури (app/Models, app/Http) на користь організації за доменами:
app/Domain/Ordering/
Models/Order.php
Actions/PlaceOrder.php
ValueObjects/Money.php
Events/OrderPlaced.php
- Бізнес-логіка живе в домені, а не в контролерах чи моделях-«божках».
- Контролери стають тонкими адаптерами, що викликають доменні дії.
DDD виправданий у складних доменах; для CRUD він додає зайвий оверхед.
П'ять принципів ООП-дизайну. У Laravel вони реалізуються природно завдяки сервіс-контейнеру.
S - Single Responsibility: клас має одну причину для зміни. На практиці - виносити бізнес-логіку з «товстих» контролерів у Service/Action-класи, валідацію - у Form Requests, логіку життєвого циклу моделі - в Observers.
O - Open/Closed: відкритий для розширення, закритий для модифікації. Приклад - драйвери Laravel (cache, queue, filesystem): новий драйвер додається через extend(), не змінюючи ядро.
L - Liskov Substitution: реалізації взаємозамінні через спільний інтерфейс без поломки логіки - напр., будь-який драйвер кешу можна підставити замість іншого.
I - Interface Segregation: багато вузьких інтерфейсів краще за один «товстий»; клас не має реалізовувати методи, які не використовує.
D - Dependency Inversion: залежати від абстракцій, а не від реалізацій. Сервіс-контейнер - пряме втілення:
class OrderController
{
public function __construct(private PaymentGateway $gateway) {} // інтерфейс
}
$this->app->bind(PaymentGateway::class, StripeGateway::class); // реалізація в провайдері
Користь: тестованість (легко підставити mock), гнучкість (зміна реалізації в одному місці). Водночас Senior знає, коли не переускладнювати - надмірна абстракція заради «чистоти» шкодить не менше за її відсутність.
Circuit Breaker захищає від каскадних збоїв при зверненні до ненадійної залежності (зовнішнє API, що «лежить»). Якщо помилок забагато - «ланцюг розривається», і запити певний час відхиляються миттєво, не витрачаючи ресурси на марні спроби.
Стани:
- Closed - усе працює, запити йдуть.
- Open - поріг помилок перевищено; запити одразу падають (fail fast).
- Half-Open - через таймаут пропускаються пробні запити; успіх → Closed, провал → знову Open.
// концептуально через Cache як лічильник збоїв
if (Cache::get('cb:payments') === 'open') {
throw new ServiceUnavailableException;
}
У Laravel реалізують через лічильники в Redis/Cache або пакети-обгортки HTTP-клієнта. Часто поєднують із retry + backoff.
- Stateful - сервер зберігає стан клієнта між запитами (наприклад, сесія у файлі/пам'яті конкретного інстансу). Тоді потрібна «липкість» (sticky sessions) або спільне сховище.
- Stateless - сервер не зберігає стану; кожен запит самодостатній і містить усе потрібне (наприклад, JWT/токен з даними автентифікації).
Stateful: сесія на сервері → потрібен спільний Redis/sticky LB
Stateless: токен у запиті → будь-який інстанс обробить запит
Stateless легше масштабувати горизонтально - інстанси взаємозамінні. У Laravel веб-частина зазвичай stateful (сесії в Redis), API - stateless (Sanctum-токени). Для масштабування цей стан виносять у спільні Redis/БД.