Питання на співбесіді з 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 лишається розумним дефолтом, доки немає потреби в екстремальній продуктивності.
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 - ім'я зміниться разом зі вмістом.
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');
- Ресурсна модель 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) і контрактні тести.
Три варіанти з різною ціною.
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 із транзакцією.
- Логіка одного агрегату - у моделі (скопи, обчислювані атрибути).
- Не плодіть абстракцій передчасно - починайте простіше, виносьте за потреби.
Стандартна структура (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.
Обмеженням у 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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії