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

Питання на співбесіді з Laravel

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

378 питань

Методом extend() контейнера - він отримує вже створений сервіс і повертає те, що віддаватиметься всім споживачам.

// AppServiceProvider::register()
$this->app->extend(GeocoderInterface::class, function (GeocoderInterface $geocoder, Application $app) {
    return new CachedGeocoder($geocoder, $app->make('cache.store'));
});
final class CachedGeocoder implements GeocoderInterface
{
    public function __construct(private GeocoderInterface $inner, private Repository $cache) {}

    public function locate(string $address): Coordinates
    {
        return $this->cache->remember('geo:' . md5($address), 86400, fn () => $this->inner->locate($address));
    }
}

Це патерн «декоратор»: той самий інтерфейс, а всередині - оригінал плюс нова поведінка. Код, що просить GeocoderInterface, нічого не помічає.

Чому це краще за альтернативи:

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

Суміжні інструменти:

  • resolving() - викликати код щоразу, коли сервіс створюється (налаштувати, а не замінити);
  • tag() і tagged() - зібрати всі реалізації інтерфейсу, щоб передати їх масивом.

Докладніше в документації: Розширення прив'язок

PHP-FPM - «shared nothing»: кожен запит стартує з чистого стану, наприкінці все звільняється. Просто, безпечно, але є оверхед бутстрапу фреймворку на кожному запиті.

Octane тримає застосунок у пам'яті між запитами → кратно вищий throughput і нижча латентність.

PHP-FPM Octane
Стан між запитами чистий зберігається
Throughput нижчий значно вищий
Ризик витоків стану немає є
Складність деплою проста вища (воркери, рестарти)

Ціна Octane: треба остерігатися «протікання» стану (статика, синглтони, глобальні змінні), правильно скидати/перезапускати воркери, уважно з пам'яттю. FPM лишається розумним дефолтом, доки немає потреби в екстремальній продуктивності.

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

chunk() гортає сторінки через OFFSET. Якщо в циклі змінювати колонку, за якою фільтрується запит, записи «з'їжджають», і частину буде пропущено.

// Погано: після першої порції активних стало на 100 менше,
// а друга порція бере OFFSET 100 - і перескакує через 100 записів
User::where('active', false)->chunk(100, function ($users) {
    $users->each->update(['active' => true]);
});

// Добре: курсор за id, а не номер сторінки
User::where('active', false)->chunkById(100, function ($users) {
    $users->each->update(['active' => true]);
});

chunkById() бере наступну порцію за id > останній, тож зміни в уже обробленому не впливають.

Як обирати інструмент для великих обсягів:

  • chunkById() / lazyById() - порціями з постійною пам'яттю; lazyById() віддає LazyCollection, з якою зручніше працювати як з потоком;
  • cursor() - один запит і по одній моделі в пам'яті, але драйвер бази часто буферизує весь результат, а жадібне завантаження зв'язків неможливе;
  • масовий update() - якщо логіка вміщається в SQL, один запит UPDATE ... WHERE у сотні разів швидший за будь-який цикл (але без подій моделей).

Що ще важливо на мільйонах рядків:

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

Докладніше в документації: Розбиття результатів на частини

Через заголовки Cache-Control і ETag - тоді браузер чи CDN не звертаються до застосунку або отримують коротку відповідь «не змінилося».

Route::middleware('cache.headers:public;max_age=3600;etag')->group(function () {
    Route::get('/privacy', PrivacyController::class);
    Route::get('/sitemap.xml', SitemapController::class);
});

Що означають директиви:

  • public - відповідь можна зберігати в спільних кешах (CDN, проксі); private - лише в браузері;
  • max_age / s_maxage - скільки секунд вважати свіжою; s_maxage - лише для спільних кешів, зручно дати CDN довший термін, ніж браузеру;
  • etag - Laravel рахує хеш тіла відповіді. Якщо браузер надіслав той самий If-None-Match, відповідь буде 304 Not Modified без тіла.

Чого ETag не дає: застосунок усе одно виконує весь запит і рендеринг - економиться лише трафік. Справжня економія серверу - коли CDN віддає відповідь сам за max_age.

Головна небезпека - персоналізовані сторінки. Якщо сторінку з ім'ям користувача чи CSRF-токеном позначити public, CDN віддасть її наступному відвідувачу. Тому:

  • public лише для сторінок, однакових для всіх;
  • сесійні cookie у відповіді зазвичай роблять її некешованою для CDN, і це правильно;
  • для авторизованих - private, no-store.

Статичні ассети з хешем у назві (Vite) кешують на рік з immutable - ім'я зміниться разом зі вмістом.

Докладніше в документації: Middleware Cache-Control

Http - обгортка над Guzzle з розумними значеннями за замовчуванням, але саме за замовчуванням і ховаються проблеми.

Базовий виклик:

$response = Http::withToken($token)
    ->timeout(5)
    ->retry(3, 200)
    ->get('https://api.example/vacancies', ['page' => 1]);

if ($response->failed()) {
    // ...
}

$data = $response->json();

Чотири речі, без яких у прод виходити не варто:

1. Таймаут. За замовчуванням запит може висіти 30 секунд, тримаючи PHP-воркер. Чужий сервіс, що «підвис», кладе ваш застосунок, а не свій. timeout(5) і connectTimeout(2) обовʼязкові.

2. Повтори з паузою. retry(3, 200) рятує від разових збоїв, але повторювати можна лише ідемпотентні запити: повтор POST про створення платежу створить його двічі.

3. Обробка помилки. Http не кидає виняток на 4xx/5xx - повертає відповідь із failed() === true. Код, що одразу робить ->json()['id'], отримає null і піде далі, ніби все гаразд. Або перевіряйте явно, або ставте ->throw().

4. Ізоляція від збоїв. Виклик чужого API під час обробки запиту робить вашу доступність залежною від чужої. Такі виклики належать у чергу, а на повторювані збої добре лягає circuit breaker.

У тестах - Http::fake(), щоб мережі не було зовсім:

Http::fake(['api.example/*' => Http::response(['id' => 1], 200)]);
Http::assertSent(fn ($request) => $request->url() === 'https://api.example/vacancies');

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

  • Ресурсна модель URL: іменники в множині (/posts, /posts/{id}/comments), дія - через HTTP-метод, а не в URL.
  • Коректні статус-коди: 200/201/204, 422 (валідація), 401/403, 404, 429.
  • API Resources для відповіді - щоб відв'язати JSON від схеми БД і контролювати формат.
  • Версіонування (/v1) із самого старту.
  • Пагінація, фільтрація, сортування через query-параметри; не віддавати все одразу.
  • Consistent error format - єдина структура помилок (Laravel дає { "message": ..., "errors": {...} } для 422).
  • Автентифікація через Sanctum/Passport, rate limiting на маршрутах.
  • Idempotency для небезпечних повторюваних операцій (платежі).
  • Документація (OpenAPI/Scribe) і контрактні тести.

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

Три варіанти з різною ціною.

paginate() - номери сторінок і загальна кількість:

{ "data": [...], "meta": { "current_page": 3, "last_page": 120, "total": 2400 }, "links": {...} }

Ціна: додатковий COUNT(*) і OFFSET, що повільнішає з номером сторінки. Підходить для адмінок і невеликих таблиць, де потрібен перехід на сторінку N.

simplePaginate() - лише «далі/назад», без COUNT(*). Дешевше, але OFFSET лишається.

cursorPaginate() - курсор від останнього запису:

select * from posts where id > 1500 order by id limit 21
  • однаково швидко на будь-якій глибині (з індексом на колонках сортування);
  • не губить і не дублює записи, коли між запитами додаються нові;
  • але немає номерів сторінок і загальної кількості, а сортування має бути за унікальною комбінацією колонок.

Для стрічок, нескінченного прокручування, синхронізації й вивантаження - курсор.

Що ще важливо в API:

  • обмежити per_page зверху (min($request->integer('per_page', 20), 100)), інакше клієнт попросить мільйон;
  • стабільне сортування з id останнім ключем - інакше записи з однаковою датою «стрибають» між сторінками;
  • якщо загальна кількість потрібна на великій таблиці - кешувати її чи віддавати приблизну.

Докладніше в документації: Курсорна пагінація чи за зміщенням

Більшість причин лежить поза кодом - у DNS і репутації домену, - але частина залежить від застосунку.

Що налаштовується в DNS:

  • SPF - перелік серверів, яким дозволено слати від імені домену.
  • DKIM - криптопідпис листа; без нього провайдер не може підтвердити, що лист не підроблено.
  • DMARC - політика на випадок, коли SPF чи DKIM не зійшлися.

Без цих трьох записів лист від нового домену з високою ймовірністю не дійде до вхідних.

Що залежить від застосунку:

  • Адреса відправника з власного домену. from виду noreply@gmail.com не пройде перевірку - домен у from має збігатися з тим, для якого налаштовані SPF і DKIM.
  • Транзакційне окремо від масового. Розсилка з поганим показником відкриттів псує репутацію домену, і за нею перестають доходити листи про відновлення пароля. Часто для розсилок беруть окремий піддомен.
  • Відписка. Для масових листів заголовок List-Unsubscribe обовʼязковий: без нього люди тиснуть «спам» замість відписки, і це найгірший сигнал.
  • Текстова версія. Лист без text/plain частина фільтрів вважає підозрілим.
  • Обробка bounce. Слати на адресу, що повертає помилку, місяцями - прямий шлях до блокування; провайдери дають вебхуки, які варто слухати.

Практично: транзакційну пошту віддають спеціалізованому сервісу (Mailgun, Postmark, SES), а не SMTP власного сервера, - вони тримають репутацію IP і дають статистику доставки. У Laravel це лише зміна MAIL_MAILER.

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

Одне з ключових архітектурних рішень. Контролери й моделі швидко «розпухають», тож логіку виносять в окремі класи.

Проблема «товстих» контролерів: контролер має лише приймати запит, делегувати роботу й повертати відповідь. Бізнес-логіка в ньому не тестується ізольовано й не перевикористовується.

Service-класи - групують пов'язану логіку домену:

class OrderService
{
    public function __construct(
        private PaymentGateway $gateway,
        private InventoryManager $inventory,
    ) {}

    public function place(User $user, Cart $cart): Order
    {
        return DB::transaction(function () use ($user, $cart) {
            $this->inventory->reserve($cart->items);
            $order = $user->orders()->create([/* ... */]);
            $this->gateway->charge($user, $cart->total);
            OrderPlaced::dispatch($order);

            return $order;
        });
    }
}

Action-класи (single-action) - один клас = одна операція. Дрібніша гранулярність, дуже тестовано:

class PlaceOrderAction
{
    public function handle(User $user, Cart $cart): Order { /* ... */ }
}

«Товсті» моделі - логіку, тісно пов'язану з даними самої моделі (скопи, аксесори, прості методи стану), доречно лишати в моделі. Складні міждоменні операції - у сервіси.

Рекомендації:

  • Контролер тонкий: запит → виклик сервісу/екшену → відповідь.
  • Складні операції з кількома моделями - у Service/Action із транзакцією.
  • Логіка одного агрегату - у моделі (скопи, обчислювані атрибути).
  • Не плодіть абстракцій передчасно - починайте простіше, виносьте за потреби.

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

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

Групування за предметною областю (модулі):

app/
  Billing/      Models, Actions, Events, Policies, BillingServiceProvider
  Catalog/
  Shipping/
  Shared/       спільне: value objects, базові класи

Laravel не нав'язує структуру - простори імен PSR-4 і провайдери дозволяють так робити без пакетів.

Що робить модулі справжніми, а не лише теками:

  • Публічна поверхня. Інші модулі звертаються до Billing через його дії чи сервіси, а не до таблиць і моделей напряму.
  • Події між модулями. OrderPlaced з Catalog слухає Billing - відправник не знає про отримувача.
  • Свої провайдери реєструють маршрути, політики, слухачі модуля.
  • Перевірка меж архітектурними тестами: arch()->expect('App\Billing')->not->toUse('App\Shipping\Models').

Чого уникати:

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

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

Модель природно притягує все, що стосується сутності: запити, обчислення, правила, побічні дії. Розподіл за видами відповідальності:

Лишається в моделі - те, що описує саму сутність: зв'язки, касти, $fillable, короткі методи стану (isPaid()).

Виноситься:

  • запити - у скопи (#[Scope] методи) або власний Query Builder моделі, коли скопів багато;
  • перетворення значень - у касти й об'єкти-значення (Money, Address) замість пари аксесорів на кожне поле;
  • логіка над набором моделей - у власну колекцію (#[CollectedBy]);
  • бізнес-операції («оформити замовлення», «повернути кошти») - у дії чи сервіси, а не методи Order::refund() на 80 рядків з HTTP-викликами;
  • побічні дії - у події й слухачі, явні в коді, а не приховані в booted();
  • правила доступу - у політики;
  • повторювана поведінка кількох моделей (slug, мультитенантність, архівування) - у трейти з boot{Trait}().

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

Мета не «тонка модель будь-якою ціною», а щоб кожна зміна мала одне очевидне місце.

Докладніше в документації: Локальні скопи

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

paginate() - класична пагінація з номерами сторінок:

$posts = Post::latest()->paginate(15);

Виконує два запити: сам вибір і count(*) для загальної кількості сторінок.

simplePaginate() - те саме без підрахунку загальної кількості, лише «далі/назад». Прибирає дорогий count(*) на великій таблиці.

cursorPaginate() - гортання за значенням ключа, без offset:

$posts = Post::latest()->cursorPaginate(15);

Чому offset повільний. limit 15 offset 100000 не перестрибує рядки - база читає й відкидає сто тисяч, перш ніж віддати п'ятнадцять. Що глибша сторінка, то повільніше; на останніх сторінках великого списку це секунди.

Курсорна пагінація натомість запитує «наступні 15 після цього значення»:

where (published_at, id) < (?, ?) order by published_at desc, id desc limit 15

Час не залежить від глибини - за умови, що є індекс на колонках сортування.

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

Ціна курсора: немає номерів сторінок і переходу «на сторінку 7» - лише вперед і назад. Сортування має бути за унікальною чи доповненою id комбінацією, інакше рядки з однаковим значенням загубляться.

Що обирати: адмінка з номерами сторінок - paginate(); довга стрічка чи API - cursorPaginate(); проміжний варіант, де потрібні лише «далі/назад» - simplePaginate().

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

Спершу треба побачити самі запити, а не здогадуватися.

Подивитися, що виконується:

// AppServiceProvider::boot()
DB::listen(function ($query) {
    if ($query->time > 100) {
        Log::warning('Slow query', ['sql' => $query->sql, 'ms' => $query->time]);
    }
});

Разово по одному запиту допомагає ->toSql() і ->dd(). У розробці - Telescope, Debugbar, Clockwork.

Прочитати план виконання. Це головний інструмент, бо він показує, чи використано індекс:

Post::where('is_published', true)->orderByDesc('published_at')->explain()->dd();

У PostgreSQL шукайте Seq Scan на великій таблиці - це повний перебір; у MySQL - type: ALL і rows, близьке до розміру таблиці.

Найчастіші причини:

  • Немає індексу на колонці з where чи order by. Для складених умов порядок колонок в індексі має значення: індекс (is_published, published_at) працює для фільтра за is_published із сортуванням, а (published_at, is_published) - ні.
  • Функція на колонці вбиває індекс: whereRaw('lower(email) = ?') змусить перебір, поки немає функціонального індексу.
  • LIKE '%текст%' індексом не користується - для пошуку потрібен повнотекстовий індекс або окремий рушій.
  • OFFSET на глибоких сторінках: offset 100000 змушує базу відкинути сто тисяч рядків. Рятує курсорна пагінація - cursorPaginate().

Індекс не безкоштовний: він сповільнює запис і займає місце. Додавати варто за фактом виміряної проблеми, а не «на всяк випадок» на кожну колонку.

Докладніше в документації: База даних

Обидва фільтрують за пов'язаною таблицею, але роблять це по-різному.

whereHas() будує підзапит EXISTS:

$posts = Post::whereHas('comments', function ($query) {
    $query->where('is_approved', true);
})->get();

Модель повертається одна й без дублікатів, зв'язок не витягується. Для простої перевірки «чи є хоч один» є коротший has('comments'), а для кількості - has('comments', '>=', 3).

join() зшиває таблиці в одному запиті:

$posts = Post::join('comments', 'comments.post_id', '=', 'posts.id')
    ->where('comments.is_approved', true)
    ->select('posts.*')
    ->distinct()
    ->get();

Різниця, яка вирішує:

  • join множить рядки: пост із десятьма коментарями повернеться десять разів, тому потрібен distinct() або groupBy - і разом з ними падає перевага в швидкості.
  • join дає доступ до колонок другої таблиці - сортувати чи вибирати за ними можна тільки так.
  • whereHas читається ближче до наміру й не ламає зв'язки моделі.

Про продуктивність. Поширене «join завжди швидший» невірне: сучасні планувальники виконують EXISTS ефективно й часто зупиняються на першому збігу, тоді як join матеріалізує всі пари. Різниця залежить від індексів і селективності - міряйте EXPLAIN, а не вгадуйте.

Практичне правило: фільтруєте за наявністю - whereHas; потрібні колонки пов'язаної таблиці для вибірки чи сортування - join.

Докладніше в документації: Eloquent: зв'язки

Обмеженням у with() - з Laravel 11 limit() усередині працює для кожного батька окремо.

$posts = Post::with([
    'comments' => fn ($query) => $query->latest()->limit(3),
])->paginate(20);

Чому це не було очевидно: жадібне завантаження - це один запит WHERE post_id IN (...) для всіх постів. Звичайний LIMIT 3 обрізав би весь результат до трьох коментарів на всю сторінку. Тепер Eloquent будує запит з віконною функцією (ROW_NUMBER() OVER (PARTITION BY post_id ...)), і кожен пост отримує свої три. У старіших версіях для цього брали пакет eager-limit або окремий запит на кожен пост.

Що ще варто обмежувати в жадібному завантаженні:

  • колонки - with('author:id,name'), обов'язково разом з ключем зв'язку;
  • умову - лише схвалені коментарі, лише активні товари.

Альтернативи, коли кількість потрібна в іншій формі:

  • лише один запис - зв'язок latestOfMany();
  • лише число - withCount();
  • дуже великі вкладені дані - окремий ендпойнт з пагінацією, а не все одразу.

Віконні функції потрібні від бази: MySQL 8+, PostgreSQL, SQLite 3.25+.

Докладніше в документації: Обмеження жадібного завантаження

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

Рівні
Junior 101 Middle 147 Senior 130

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