Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Провайдери завантажуються у дві фази, і плутанина між ними дає помилки, які важко пояснити.
register() - тільки привʼязки до контейнера. На цей момент інші провайдери ще не зареєстровані, тож звертатися до чужих сервісів не можна:
public function register(): void
{
$this->app->singleton(WeatherClient::class, function ($app) {
return new WeatherClient(config('services.weather.key'));
});
}
Замикання виконається пізніше - у момент першого резолву, коли все вже піднято.
boot() - усе інше. Викликається після того, як усі провайдери відпрацювали register(), тож тут доступні будь-які сервіси:
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
View::composer('layouts.app', SidebarComposer::class);
Gate::define('publish', fn (User $user) => $user->is_editor);
}
Типова помилка - зробити в register() щось на кшталт:
public function register(): void
{
// Провайдер конфігурації міг ще не відпрацювати.
$key = config('services.weather.key');
$this->app->singleton(WeatherClient::class, fn () => new WeatherClient($key));
}
Значення читається негайно, і якщо потрібний провайдер ще не завантажився, отримаєте null без жодної помилки. Всередині замикання те саме читання безпечне.
Правило: register() - сказати контейнеру, як створювати; boot() - зробити щось із уже готовим застосунком.
AppServiceProvider - зручне місце за замовчуванням, і для невеликого застосунку його цілком досить. Проблема починається, коли він розростається до сотень рядків змішаних налаштувань.
Ознаки, що час розділяти:
- у
boot()упереміш макроси, правилаModel::shouldBeStrict(),RateLimiter::for(),Gate::before(),Event::listen()і прив'язки інтеграцій; - щоб змінити одну інтеграцію, доводиться гортати весь файл;
- різні частини належать різним модулям чи командам.
Як розділяють: за предметною областю, а не за типом коду.
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class, // загальні налаштування фреймворку
App\Providers\BillingServiceProvider::class, // усе про оплату: шлюз, вебхуки, лімітери
App\Providers\SearchServiceProvider::class, // клієнт пошуку, індексатори
];
Провайдер модуля тримає прив'язки, події й політики свого модуля - тоді модуль можна зрозуміти, відкривши один файл.
Чого уникати:
- провайдер на кожен рядок - десяток порожніх класів гірший за один охайний файл;
- важкої роботи в
boot()- він виконується на кожен запит, тож запит до бази чи HTTP-виклик там гальмує все; - залежності від порядку провайдерів у
register(): там можна лише реєструвати, а користуватися сервісами - уboot().
Провайдер, що лише реєструє прив'язки, можна зробити відкладеним (DeferrableProvider).
Через властивості $bindings і $singletons замість викликів у register().
class AppServiceProvider extends ServiceProvider
{
public array $bindings = [
ServerProvider::class => DigitalOceanServerProvider::class,
];
public array $singletons = [
DowntimeNotifier::class => PingdomDowntimeNotifier::class,
ServerToolsProvider::class => ServerToolsProvider::class,
];
}
Фреймворк сам пройде ці масиви, коли завантажуватиме провайдер, і зареєструє кожну пару «абстракція - реалізація».
Коли цього досить: прив'язка інтерфейсу до класу, який контейнер може створити сам.
Коли потрібен register(): якщо об'єкт треба створити по-особливому - з параметрами з конфігурації, з умовою, з замиканням:
public function register(): void
{
$this->app->singleton(PaymentGateway::class, fn () => new StripeGateway(
config('services.stripe.secret'),
));
}
Ще одна зручність: метод boot() теж підтримує впровадження залежностей - потрібні сервіси оголошують його параметрами, а не дістають через $this->app->make().
public function boot(ResponseFactory $response): void
{
$response->macro('caps', fn (string $value) => $response->make(strtoupper($value)));
}
Докладніше в документації: Властивості bindings і singletons
Події дають слабке зв'язування: одна частина застосунку «оголошує», що щось сталося, інші - реагують, нічого не знаючи одна про одну.
event(new OrderShipped($order)); // диспатч
// слухач
class SendShipmentNotification
{
public function handle(OrderShipped $event): void
{
// ...
}
}
- Слухача, що реалізує
ShouldQueue, обробляють асинхронно в черзі. - У сучасному Laravel слухачі автоматично виявляються за type-hint у методі
handle- ручна реєстрація не обов'язкова.
Приклад: подія UserRegistered → слухачі «надіслати лист», «нарахувати бонус», «оновити статистику».
Подія повідомляє, що щось сталося, не знаючи, хто на це зреагує. Це розвʼязує звʼязок між тим, хто діє, і тим, хто відповідає.
OrderPlaced::dispatch($order);
Слухачі - лист, нарахування бонусів, повідомлення на склад - підписані окремо, і додати ще одного можна, не чіпаючи оформлення замовлення.
Коли події доречні:
- На одну дію треба кілька незалежних реакцій, і їхній перелік з часом росте.
- Реакції належать іншим доменам - розсилка, аналітика, інтеграції.
- Реакція має піти у фонову роботу (
ShouldQueueна слухачі). - Реагувати мусить пакет чи модуль, який про ваш код не знає.
Коли краще виклик напряму:
- Реакція єдина й завжди та сама.
OrderPlacedз одним слухачем - це виклик методу, записаний у два файли. - Порядок і результат важливі: подія не повертає значення й не гарантує послідовності.
- Дія - частина тієї самої операції, яка мусить відкотитися разом із нею.
Головна пастка - невидимість. Через рік ніхто не скаже, що саме відбувається під час оформлення замовлення: у коді видно один рядок, а реально виконуються сім слухачів у різних файлах. Налагодження зводиться до пошуку по всьому проєкту.
Друга пастка - транзакції. Подія, відправлена всередині DB::transaction(), може дійти до черги раніше, ніж транзакція завершиться, - і воркер не знайде запису. Для цього є ShouldDispatchAfterCommit на події або afterCommit на завданні.
Правило: починайте з прямого виклику. Переходьте на подію, коли реакцій стало більше однієї або вони належать іншій частині системи.
Бо подію диспетчеризували всередині транзакції, а воркер узяв завдання раніше, ніж транзакція закомітилася.
DB::transaction(function () use ($data) {
$order = Order::create($data);
OrderPlaced::dispatch($order); // слухач у черзі вже може стартувати
$order->items()->createMany($data['items']);
});
Воркер читає замовлення з бази - а там його ще немає, або немає позицій. Якщо транзакція відкотиться, слухач узагалі обробить те, чого не існує.
Рішення:
ShouldQueueAfterCommitу слухачі - стати в чергу лише після коміту;ShouldDispatchAfterCommitу події - диспетчеризувати саму подію після коміту (не дістануть і синхронні слухачі);after_commit => trueу конфігурації підключення черги - для всіх завдань;- для спостерігачів -
ShouldHandleEventsAfterCommit.
Якщо транзакції немає, усе це спрацьовує одразу, тож поведінка поза транзакцією не змінюється.
Те саме стосується листів і сповіщень у черзі - у них є afterCommit().
Два різні питання - «чи відправлено подію» і «чи правильно працює слухач» - тестують окремо.
Чи відправлено:
Event::fake([OrderShipped::class]);
$this->post("/orders/{$order->id}/ship")->assertOk();
Event::assertDispatched(OrderShipped::class, fn ($e) => $e->order->is($order));
Передавати список подій важливо: Event::fake() без аргументів підмінить усі події, зокрема події моделей. Тоді зламається все, що тримається на creating - генерація UUID, slug, спостерігачі - і тест впаде з дивною помилкою.
Інші інструменти:
Event::fakeExcept([...])- підмінити все, крім переліченого;Event::fakeFor(fn () => ...)- лише для частини тесту;Event::assertListening(OrderShipped::class, SendShipmentNotification::class)- що слухач підписаний.
Чи правильно працює слухач - окремий тест, де слухач викликають напряму:
(new SendShipmentNotification)->handle(new OrderShipped($order));
Notification::assertSentTo($order->customer, ShipmentSent::class);
Варто мати й кілька тестів без підмін, де ланцюжок «дія → подія → слухач» проходить цілком: саме там ламаються інтеграції, які фейки приховують.
Queue дозволяє відкласти важку роботу (Job) у фон, щоб не змушувати користувача чекати.
class ProcessPodcast implements ShouldQueue
{
public function handle(): void { /* важка робота */ }
}
ProcessPodcast::dispatch($podcast)->onQueue('media');
- Драйвери черг:
database,redis,sqs(config/queue.php). - Воркер обробляє завдання:
php artisan queue:work. - Підтримка повторів (
$tries,backoff), затримок (->delay()), middleware для завдань.
Типове застосування: email, обробка зображень, виклики зовнішніх API, генерація звітів.
Обидва групують завдання, але з протилежною метою.
Ланцюжок (Bus::chain) - послідовність. Наступне завдання стартує лише після успіху попереднього; якщо одне впало, решта не виконується.
Bus::chain([
new ProcessPodcast($podcast),
new OptimizePodcast($podcast),
new ReleasePodcast($podcast),
])->catch(fn (Throwable $e) => report($e))->dispatch();
Підходить, коли кроки залежать один від одного: не можна публікувати те, що ще не оброблено.
Пакет (Bus::batch) - паралельність. Завдання виконуються незалежно, кількома воркерами одночасно, а колбеки then, catch, finally спрацьовують, коли пакет завершено.
Bus::batch($users->map(fn ($u) => new SendNewsletter($u)))
->then(fn (Batch $batch) => Log::info('Розсилку завершено'))
->allowFailures()
->dispatch();
Підходить для великої кількості однотипної роботи: розсилка, імпорт частинами. Пакет показує прогрес, але порядку не гарантує.
Деталі: пакетам потрібна таблиця job_batches (make:queue-batches-table), а завдання - з трейтом Batchable. Скасування пакета не зупиняє вже взяті завдання, тож кожне перевіряє $this->batch()->cancelled() на початку.
Це обгортки навколо handle(), як middleware маршрутів навколо контролера. Вони виносять з завдання повторювану логіку: обмеження, блокування, умови пропуску.
public function middleware(): array
{
return [
new WithoutOverlapping($this->user->id),
new RateLimited('external-api'),
];
}
Вбудовані, які найчастіше потрібні:
WithoutOverlapping- не дає двом завданням з тим самим ключем виконуватися одночасно (оновлення балансу одного користувача).RateLimited- тримає частоту звернень у межах ліміту, оголошеного черезRateLimiter::for(); зайві завдання повертаються в чергу.ThrottlesExceptions- якщо зовнішній сервіс почав падати, перестає його смикати на якийсь час.Skip::when(...)- видаляє завдання без виконання за умовою, наприклад якщо замовлення вже скасоване.
Пастка: повернення в чергу через WithoutOverlapping чи RateLimited теж рахується як спроба. З однією спробою за замовчуванням таке завдання одразу потрапить у failed_jobs - тому для них збільшують #[Tries] або обмежують за часом.
Розвести їх по різних чергах і сказати воркеру, яку брати першою.
SendPasswordReset::dispatch($user)->onQueue('high');
SendNewsletter::dispatch($user); // у default
php artisan queue:work --queue=high,default
Порядок у --queue - це пріоритет: поки в high є завдання, default чекає. Лист для скидання пароля більше не стоїть за п'ятдесятьма тисячами розсилки.
Що врахувати:
- Голодування. Якщо
highніколи не порожніє,defaultне отримає жодного воркера. Надійніше дати важливим чергам окремі воркери, а не лише пріоритет. - Маршрутизація. У Laravel 13
Queue::route(SendPasswordReset::class, queue: 'high')у провайдері задає чергу для класу, тож не треба писатиonQueue()в кожному місці. - Horizon дозволяє описати супервізорів з різною кількістю процесів на чергу й балансувати їх автоматично.
Не сама модель, а її клас і первинний ключ. Воркер, беручи завдання, перечитує модель з бази.
class ProcessOrder implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
}
Що з цього випливає:
- Дані свіжі на момент виконання, а не на момент постановки. Якщо замовлення встигли змінити, завдання побачить зміни.
- Завантажені зв'язки теж перечитуються - і роздувають payload. Їх відкидають
->withoutRelations()або атрибутом#[WithoutRelations]на властивості. - Модель могли видалити. Тоді завдання впаде з
ModelNotFoundException- без повторів. Якщо це нормальна ситуація (користувач видалив акаунт), атрибут#[DeleteWhenMissingModels]просто видалить завдання. - Незбережені зміни губляться. Атрибути, змінені без
save()передdispatch(), у завдання не потраплять - воркер прочитає те, що лежить у базі.
Тому в завдання передають модель або ID, але не масиви її даних: інакше воркер працюватиме із застарілою копією.
Поліморфний зв'язок дозволяє моделі належати кільком різним типам моделей через один зв'язок.
class Comment extends Model
{
public function commentable(): MorphTo
{
return $this->morphTo();
}
}
class Post extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
Таблиця comments має commentable_id + commentable_type. Тож Comment може належати і Post, і Video без окремих таблиць. Бувають також many-to-many поліморфні зв'язки (morphToMany), напр. теги.
Єдиний API поверх драйверів для зберігання результатів важких обчислень чи запитів. Драйвери: database (за замовчуванням у нових застосунках), file, redis, memcached, dynamodb, array (для тестів). Задається через CACHE_STORE у .env.
remember - найпоширеніший патерн (дістати з кешу або обчислити й закешувати):
$users = Cache::remember('active_users', 3600, function () {
return User::where('active', true)->get();
});
Cache::put('key', $value, now()->addMinutes(10));
$value = Cache::get('key', 'default');
Cache::forget('key');
Теговане кешування (лише Redis/Memcached) - для групового скидання:
Cache::tags(['posts'])->put('post.1', $post, 600);
Cache::tags(['posts'])->flush(); // скинути всю групу
Атомарні блокування проти гонок (один процес у критичній секції):
Cache::lock('processing', 10)->get(function () {
// критична секція
});
Найскладніше - інвалідація: кеш скидають у подіях/обзерверах моделей при зміні даних. Застарілий кеш часто гірший за його відсутність.
Найскладніше в кешуванні - не покласти значення, а вчасно його прибрати. Є три основні підходи.
1. Явне видалення при зміні:
class Category extends Model
{
protected static function booted(): void
{
static::saved(fn () => Cache::forget('categories:menu'));
static::deleted(fn () => Cache::forget('categories:menu'));
}
}
Просто, але треба пам'ятати всі ключі, які залежать від моделі. Масові оновлення Category::query()->update() подій моделі не викликають - кеш лишиться старим.
2. Теги - скинути групу ключів однією командою:
Cache::tags(['posts', 'user:42'])->remember('user:42:posts', 3600, fn () => /* ... */);
Cache::tags('posts')->flush(); // усе, що стосується постів
Працює лише з драйверами, що підтримують теги (Redis, Memcached), - не з file і database.
3. Версія в ключі - нічого не видаляти, а змінювати ключ:
$version = Cache::rememberForever('posts:version', fn () => 1);
$posts = Cache::remember("posts:v{$version}:page:{$page}", 3600, fn () => /* ... */);
// при зміні
Cache::increment('posts:version');
Старі ключі просто перестають читатися й самі зникають за TTL. Працює з будь-яким драйвером.
Правило: короткий TTL - страховка на випадок, якщо скидання десь забули. Довгий TTL без надійного скидання - гарантовані скарги на застарілі дані.
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 147 відкритих вакансій Laravel і PHP. Переглянути вакансії