Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Звичайний вебзастосунок відповідає лише на запит: сторінка оновиться тоді, коли її оновить користувач. Бродкастинг дозволяє серверу самому надіслати повідомлення у відкритий браузер - без перезавантаження й без опитування.
Навіщо: чат, сповіщення, лічильник онлайн, прогрес довгого завдання, оновлення списку без кнопки «оновити».
Як це виглядає. Подія позначається інтерфейсом:
class VacancyPublished implements ShouldBroadcast
{
public function __construct(public Vacancy $vacancy)
{
}
public function broadcastOn(): Channel
{
return new Channel('vacancies');
}
}
Далі звичайне VacancyPublished::dispatch($vacancy) - і подія йде не лише слухачам на сервері, а й у браузер.
На клієнті її слухає Echo:
Echo.channel('vacancies')
.listen('VacancyPublished', (e) => {
console.log(e.vacancy.title);
});
Що потрібно, крім коду: окремий сервіс, який тримає WebSocket-зʼєднання - Laravel Reverb (власний), Pusher чи Ably. PHP-процес сам зʼєднання не тримає.
Альтернатива, про яку варто памʼятати. Якщо оновлення рідкісні або потрібні лише одному користувачеві, звичайне опитування раз на кілька секунд чи wire:poll у Livewire простіші й не вимагають окремого сервера. Бродкастинг виправданий, коли подій багато, вони мають доходити миттєво й до багатьох одразу.
Mailable - це лист. Notification - це повідомлення, яке може піти листом, у базу, в Slack чи кудись ще, і канал вибирається окремо від змісту.
Створення:
php artisan make:notification VacancyPublished
class VacancyPublished extends Notification
{
public function __construct(public Vacancy $vacancy)
{
}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Вашу вакансію опубліковано')
->line("«{$this->vacancy->title}» тепер видно всім.")
->action('Переглянути', route('vacancies.show', $this->vacancy));
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return ['vacancy_id' => $this->vacancy->id];
}
}
Відправлення:
$user->notify(new VacancyPublished($vacancy));
// або багатьом
Notification::send($users, new VacancyPublished($vacancy));
Метод notify() доступний завдяки трейту Notifiable на моделі.
Що дає канал database: сповіщення зберігається в таблиці notifications, і з нього робиться «дзвіночок» в інтерфейсі - $user->unreadNotifications.
Коли достатньо Mailable: одноразовий лист без варіантів доставки - чек, звіт, підтвердження форми. Notification доречний, коли те саме повідомлення має йти різними каналами або користувач сам обирає, як його повідомляти.
Драйвер задається в config/cache.php і змінною CACHE_STORE. Вибір визначає не швидкість, а те, чи бачать процеси спільний кеш.
array - у памʼяті одного процесу, зникає після запиту. Стандартний драйвер для тестів: кеш не тягнеться між тестами й нічого не треба чистити.
file - файли в storage/framework/cache. Працює без жодного налаштування, тому зручний локально. На кількох серверах кожен матиме свій кеш, а очищення тегів файловий драйвер не підтримує.
database - таблиця в базі. Спільний для всіх серверів, але кожне читання - це запит до тієї самої бази, яку кеш і мав розвантажити.
redis / memcached - окремий сервер у памʼяті. Спільний для всіх процесів, витримує теги й атомарні блокування. Це стандартний вибір для проду.
Практичне правило: локально file, у тестах array, у проді redis.
Що важливо пам'ятати про різницю. Код, написаний під Redis, може мовчки не працювати на file:
Cache::tags(['posts'])->put('list', $posts, 600); // file-драйвер кине виняток
Блокування (Cache::lock()) файловий драйвер підтримує, але блокування живе на диску одного сервера - на кількох вузлах воно вже нічого не гарантує.
Тому середовища краще тримати на однаковому драйвері - інакше помилка знайдеться вже в проді.
Cache::remember() повертає значення з кешу, а якщо його немає - виконує замикання, зберігає результат і повертає.
$categories = Cache::remember('categories:menu', now()->addHour(), function () {
return Category::query()->orderBy('position')->get(['id', 'name', 'slug']);
});
Перший запит за годину піде в базу, решта отримають готовий результат.
Ключ:
- має містити все, від чого залежить результат:
posts:page:3,user:42:stats,products:locale:uk. Забули мову в ключі - українці побачать англійське меню; - з префіксом за сутністю - щоб було зрозуміло, що це, і легко скинути.
Час життя (TTL):
- скільки часу застарілі дані прийнятні для користувача: курс валют - хвилини, меню категорій - години;
rememberForever()- лише коли кеш гарантовано скидають при змінах, інакше застарілі дані житимуть вічно.
Чого уникати:
- кешувати те, що й так швидке: простий запит за первинним ключем часто швидший за звернення до Redis з десеріалізацією;
- кешувати дані конкретного користувача під спільним ключем - це витік чужих даних.
Найпростіший пошук - фільтр за кількома колонками, і для більшості сайтів цього достатньо надовго.
Запит:
public function index(Request $request)
{
$term = $request->string('q')->trim()->toString();
$vacancies = Vacancy::query()
->when($term !== '', function ($query) use ($term) {
$query->where(function ($query) use ($term) {
$query->where('title', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%");
});
})
->paginate(15)
->withQueryString();
return view('vacancies.index', compact('vacancies'));
}
Дві деталі, на яких зазвичай помиляються:
1. Дужки навколо orWhere. Без вкладеного замикання умова «розклеїться»: where(A)->orWhere(B) разом з іншими фільтрами дасть фільтр AND A OR B, і пошук почне повертати чуже. Вкладене замикання групує умови правильно.
2. Порожній запит. Без when() пошук за порожнім рядком перетвориться на like '%%' - формально працює, але виконує зайву роботу.
Про безпеку: підставляти значення в where() безпечно - Eloquent параметризує запит. Небезпечним є лише whereRaw("title like '%{$term}%'") зі вставкою рядка напряму.
Коли цього перестане вистачати: like '%...%' не використовує індекс і не знає словоформ, тож на десятках тисяч записів пошук стає повільним, а «розробники» не знаходять «розробник». Тоді переходять на повнотекстовий індекс бази або Laravel Scout.
Контролер - це шар 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, або контролер перестав вміщатися на екран.
Обидва кажуть контейнеру, як створювати сервіс. Різниця - у тому, скільки разів це станеться.
bind() створює новий екземпляр щоразу:
$this->app->bind(ReportBuilder::class, function ($app) {
return new ReportBuilder($app->make(Connection::class));
});
app(ReportBuilder::class) === app(ReportBuilder::class); // false
singleton() створює один раз і повертає той самий обʼєкт до кінця запиту:
$this->app->singleton(WeatherClient::class, function ($app) {
return new WeatherClient(config('services.weather.key'));
});
app(WeatherClient::class) === app(WeatherClient::class); // true
Є ще scoped() - як singleton, але скидається на кожному запиті. Це важливо для Octane, де застосунок живе між запитами й звичайний singleton зберігав би стан довше, ніж треба.
Що обирати. Singleton доречний, коли створення дороге (клієнт із конфігурацією, зʼєднання) або коли стан має бути спільним. bind() - коли обʼєкт накопичує стан і два різних місця не повинні його ділити.
Головна пастка singleton - утримання стану. Сервіс, що накопичує щось усередині, віддасть ці дані наступному, хто його запросить:
class Basket
{
private array $items = [];
public function add(Item $item): void
{
$this->items[] = $item; // у singleton лишиться на весь запит
}
}
Під Octane це вже не «на запит», а між запитами - і дані одного користувача протікають до іншого. Тому в singleton кладуть сервіси без стану, а стан тримають явно.
Контейнер уміє будувати класи сам: читає конструктор через рефлексію й рекурсивно створює залежності.
class InvoiceService
{
public function __construct(private PdfRenderer $pdf, private Mailer $mailer) {}
}
// у контролері достатньо оголосити - контейнер створить усе дерево
public function send(InvoiceService $invoices) { /* ... */ }
Без прив'язки працює, якщо всі залежності - конкретні класи без скалярних параметрів.
Прив'язка потрібна в трьох випадках:
- Інтерфейс. Контейнер не вгадує реалізацію:
$this->app->bind(PaymentGateway::class, StripeGateway::class);
Без цього - виняток Target [PaymentGateway] is not instantiable.
-
Скалярні параметри - ключ API, таймаут. Їх передають контекстною прив'язкою чи атрибутом
#[Config('services.stripe.key')]. -
Особливий спосіб створення або один екземпляр - клієнт з налаштуванням,
singleton()для дорогого об'єкта.
Порада: не реєструйте прив'язку для кожного класу «про всяк випадок». Конкретні класи контейнер створить сам, а зайві рядки в провайдері лише ускладнюють пошук того, що справді налаштовано.
У файлі bootstrap/providers.php - він повертає масив класів провайдерів застосунку.
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\BillingServiceProvider::class,
];
Новий провайдер:
php artisan make:provider BillingServiceProvider
Команда створить клас і сама допише його в bootstrap/providers.php. Створений вручну клас треба додати туди самостійно, інакше Laravel про нього не знає.
Що змінилося: до Laravel 11 провайдери перелічували в масиві providers у config/app.php, а в застосунку їх було п'ять-шість стандартних. Тепер у свіжому проєкті один AppServiceProvider, а реєстрація винесена в окремий короткий файл.
Провайдери пакетів зазвичай не реєструють узагалі: Composer-пакет оголошує свій провайдер у composer.json, і Laravel підхоплює його сам (package discovery).
У самому провайдері - два методи: register() для прив'язок у контейнер і boot() для всього, що спирається на інші сервіси: макроси, події, політики, Model::preventLazyLoading().
paginate() рахує сторінки через OFFSET, а cursorPaginate() продовжує від останнього показаного запису.
Post::query()->orderBy('id')->paginate(20);
// SELECT ... ORDER BY id LIMIT 20 OFFSET 19980 (сторінка 1000)
// + SELECT COUNT(*) ...
Post::query()->orderBy('id')->cursorPaginate(20);
// SELECT ... WHERE id > 19980 ORDER BY id LIMIT 21
Чому курсор швидший на далеких сторінках: OFFSET 19980 змушує базу прочитати й відкинути майже 20 тисяч рядків. Умова id > ... за індексом одразу стає на потрібне місце - тисячна сторінка відкривається так само швидко, як перша. До того ж немає запиту COUNT(*), який на великих таблицях теж дорогий.
Ще одна перевага: якщо поки користувач гортає, додаються нові записи, OFFSET зсуває сторінки - записи повторюються чи пропадають. Курсор такого не має.
Обмеження курсора:
- немає номерів сторінок і загальної кількості - лише «далі» й «назад»;
- сортування має бути за унікальною колонкою чи їх комбінацією (
created_at+id); - колонки сортування мають бути в індексі.
Коли що: нескінченна стрічка, API, експорт - курсор. Таблиця в адмінці з номерами сторінок - звичайна пагінація, simplePaginate() якщо загальна кількість не потрібна.
Подія - простий клас з даними про те, що сталося. Слухач - клас, що реагує.
php artisan make:event OrderShipped
php artisan make:listener SendShipmentNotification --event=OrderShipped
class OrderShipped
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}
class SendShipmentNotification
{
public function handle(OrderShipped $event): void
{
$event->order->customer->notify(new ShipmentSent($event->order));
}
}
Реєстрація не потрібна: Laravel сам знаходить слухачів у app/Listeners за типом параметра handle(). Вручну - через Event::listen() у провайдері.
Диспетчеризація:
OrderShipped::dispatch($order);
// або
event(new OrderShipped($order));
Навіщо це: код, що відправляє замовлення, не знає, хто реагує - лист, аналітика, склад. Нову реакцію додають новим слухачем, не чіпаючи відправника.
Повільних слухачів (листи, HTTP) роблять ShouldQueue - тоді вони виконуються у воркері, а не в запиті.
Через фасад Http - обгортку над Guzzle з простішим API.
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.github.token'))
->acceptJson()
->timeout(5)
->get('https://api.github.com/repos/laravel/framework', ['per_page' => 10]);
$stars = $response->json('stargazers_count');
Http::post('https://api.example.com/orders', ['sku' => 'A-1', 'qty' => 2]); // тіло в JSON
Що повертає: об'єкт Response з методами:
json('key.nested'),collect(),object(),body()- дані;status(),successful(),failed(),clientError(),serverError()- статус;header('X-RateLimit-Remaining')- заголовки.
Корисне з першого дня:
- дані POST за замовчуванням ідуть як JSON; для форми -
asForm(), для файлів -attach(); timeout()- не чекати 30 секунд за замовчуванням;- ключі API - у
config/services.phpі.env, а не в коді.
Головна відмінність від «голого» Guzzle: на відповіді 4xx і 5xx клієнт не кидає винятків - статус перевіряють самі або викликають throw().
Бо помилкова відповідь - теж відповідь: її можна прочитати, залогувати, обробити по-різному залежно від коду. Тому клієнт повертає Response і лишає рішення вам.
$response = Http::get($url);
if ($response->notFound()) {
return null; // немає - нормальна ситуація
}
if ($response->serverError()) {
Log::warning('API недоступне', ['status' => $response->status()]);
throw new ServiceUnavailable;
}
Коли потрібен виняток - throw():
$data = Http::get($url)->throw()->json(); // RequestException на 4xx/5xx
Http::get($url)->throwIf(fn ($r) => $r->status() >= 500);
Http::get($url)->throwUnlessStatus(200);
Типова помилка новачка:
$user = Http::get($url)->json(); // на 500 тут буде тіло помилки, а не користувач
$user['name']; // і далі - дивна помилка в іншому місці
Окремий випадок - немає відповіді зовсім (таймаут, відмова з'єднання): тоді кидається ConnectionException завжди, бо читати нічого.
Правило: на кожен зовнішній виклик - або явна перевірка статусу, або throw().
Laravel має вбудований маршрут перевірки стану: /up повертає 200, якщо застосунок завантажився без винятків, і 500 - якщо ні.
// bootstrap/app.php
->withRouting(
web: __DIR__.'/../routes/web.php',
health: '/up',
)
Хто його опитує:
- балансувальник - не слати трафік на екземпляр, що не відповідає;
- оркестратор (Kubernetes, Docker Swarm) - перезапустити контейнер;
- моніторинг доступності - сповістити команду.
Чого він не перевіряє: базу, Redis, черги. Застосунок може завантажитися й віддати 200, а база при цьому лежить. Для глибшої перевірки слухають подію DiagnosingHealth, яку маршрут запускає, і кидають виняток, якщо залежність недоступна - тоді відповідь буде 500.
Обережно з глибиною: якщо балансувальник знімає всі екземпляри через те, що впала база, сайт не віддасть навіть сторінку «технічні роботи». Тому часто розділяють «живий» (процес працює) і «готовий» (залежності доступні).
Маршрут варто виключити з журналів доступу й аналітики - його опитують щохвилини.
Коли під час запиту виникає виняток і ніхто його не перехопив, Laravel передає його обробнику винятків. Обробник робить дві незалежні речі:
| Крок | Що відбувається | Для кого |
|---|---|---|
| report (звітування) | запис у лог, відправка в Sentry, Flare, Bugsnag | для розробників |
| render (рендеринг) | перетворення винятку на HTTP-відповідь | для користувача |
виняток → report: laravel.log, Sentry
→ render: сторінка 500, JSON {"message": "..."}, редірект з помилками валідації
Чому це розділено: те, що бачить користувач, і те, що бачить розробник, мають бути різними. Користувачу - зрозуміле повідомлення без деталей, розробнику - повний стек викликів, контекст запиту, користувач.
Як Laravel рендерить різні винятки:
ValidationException- редірект назад з помилками й старим введенням, або 422 з JSON для API;AuthenticationException- редірект на сторінку входу чи 401;AuthorizationException- 403;ModelNotFoundException(відfindOrFail) - 404;HttpException(відabort(404)) - відповідний код;- будь-що інше - 500.
Деякі винятки взагалі не звітуються: валідація, 404, помилки автентифікації - це очікувані ситуації, а не баги. Інакше лог заповнився б шумом.
JSON чи HTML: якщо запит очікує JSON (заголовок Accept: application/json), Laravel повертає помилку у форматі JSON.
APP_DEBUG:
true- сторінка помилки зі стеком викликів, кодом і змінними - лише для локальної розробки;false- загальна сторінка «Server Error» без деталей. На продакшені тільки так.
Налаштовується все в bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->report(function (PaymentFailed $e) {
// власне звітування
});
$exceptions->render(function (PaymentFailed $e, Request $request) {
return response()->view('errors.payment', status: 402);
});
})
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 147 відкритих вакансій Laravel і PHP. Переглянути вакансії