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

Питання на співбесіді: Архітектура

Питання з реальних співбесід з відповідями: 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.

Докладніше в документації: 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, другий - про бізнес-логіку, яку можна викликати звідусіль.

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

Шлях запиту:

  1. public/index.php - єдина точка входу; підключає автозавантажувач Composer.
  2. Створюється екземпляр застосунку (Service Container) із bootstrap/app.php.
  3. HTTP Kernel обробляє запит, завантажує Service Providers (register → boot).
  4. Запит проходить глобальні middleware (наприклад, обробка сесій, CSRF).
  5. Router зіставляє URL із маршрутом, виконуються middleware маршруту.
  6. Викликається контролер/замикання, формується Response.
  7. Відповідь проходить 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/БД.