Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Найнебезпечніший збій планувальника - тиша: cron зламався після міграції сервера, і резервні копії не робилися місяць.
Хуки за результатом:
Schedule::command('backup:run')
->dailyAt('3:00')
->onFailure(fn () => Log::critical('Резервна копія не вдалася'))
->pingOnSuccess('https://hc.example.com/ping/backup');
onSuccess() і onFailure() дивляться на код виходу команди - тож вона має повертати ненульовий код при збої.
Пінг «я живий» (dead man's switch): зовнішній сервіс чекає пінг за розкладом і тривожить, якщо його немає. Це ловить і збій команди, і те, що планувальник узагалі не запускався. pingBefore(), pingOnSuccess(), pingOnFailure().
Вивід: sendOutputTo() / appendOutputTo() у файл, emailOutputOnFailure() - листом лише при збої.
Сам планувальник:
- cron-рядок
* * * * * php artisan schedule:runчиschedule:workпід supervisor у контейнері; schedule:listпісля деплою - що й коли виконається;- події
ScheduledTaskFailed,ScheduledTaskFinished- для власного моніторингу; schedule:pauseна час обслуговування - і не забутиschedule:continue.
Pulse показує повільні й невдалі завдання черги, тож завдання, які планувальник лише ставить у чергу, видно і там.
Головний ризик - несумісність схеми зі старим кодом під час деплою та блокування таблиць.
Безпечні зміни (expand → migrate → contract):
- Додати нову колонку (nullable) - старий код працює.
- Задеплоїти код, що пише і в стару, і в нову.
- Перенести дані (фоновий job), перемкнути читання.
- Окремим релізом видалити стару колонку.
Практики:
- Не покладатися на
migrate:rollbackу проді -down()може втрачати дані. Краще forward-fix. - Великі
ALTERна величезних таблицях блокують → онлайн-міграції (pt-online-schema-change, gh-ost). php artisan migrate --forceу пайплайні;--isolated, щоб не виконати паралельно на кількох воркерах.- Бекап перед руйнівними операціями; прогін міграцій на staging.
Реплікація розвантажує основний сервер: запис іде на primary, читання - на replicas. Laravel маршрутизує запити автоматично, якщо в конфізі з'єднання задані секції read/write:
'mysql' => [
'read' => ['host' => ['10.0.0.2', '10.0.0.3']], // репліки
'write' => ['host' => ['10.0.0.1']], // primary
'sticky' => true,
// ...спільні параметри
],
SELECT→ репліка,INSERT/UPDATE/DELETE→ primary.sticky => trueкритично важливе: після запису в межах того ж запиту читання теж піде з primary, інакше через replication lag можна прочитати застарілі дані.- Реплікація асинхронна → завжди закладайте можливе відставання реплік у логіці.
Файл, покладений у storage/app, недоступний ззовні - це правильно за замовчуванням. Питання в тому, як віддати його тому, кому можна.
Що робити не варто: класти приватні файли в public/ чи публічний бакет і покладатися на те, що адресу ніхто не вгадає. Посилання розходяться, індексуються й лишаються робочими назавжди.
Спосіб 1 - віддати через контролер із перевіркою прав:
public function download(Document $document)
{
$this->authorize('view', $document);
return Storage::disk('private')->download($document->path, $document->name);
}
Кожне звернення проходить авторизацію. Мінус - файл іде крізь PHP, тож на великих файлах воркер зайнятий увесь час передачі.
Спосіб 2 - тимчасове підписане посилання до S3-сумісного сховища:
$url = Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(5));
Посилання підписане й саме перестає працювати після строку. Файл віддає сховище напряму, PHP у передачі не бере участі - це те, що потрібно для великих файлів.
Спосіб 3 - підписаний маршрут, коли сховище тимчасових URL не вміє:
URL::temporarySignedRoute('documents.download', now()->addMinutes(5), ['document' => $document->id]);
Middleware signed перевіряє підпис і строк.
Про що часто забувають:
- Спосіб 1 без
authorize()перетворюється на публічний доступ через власний контролер. - Строк життя посилання має бути коротким: доки воно живе, ним може скористатися будь-хто, кому воно потрапило.
- Імʼя файла від користувача не можна підставляти в шлях -
Storage::putFile()генерує безпечне імʼя сам.
Головне правило - не тримати файл цілком у пам'яті PHP. Storage::get() на файлі в 2 ГБ упреться в memory_limit.
Запис потоком:
Storage::disk('s3')->putFile('videos', $request->file('video')); // потоком, а не get/put
Storage::disk('s3')->writeStream('backup.sql.gz', fopen($path, 'r'));
putFile() і putFileAs() передають файл потоком самі.
Читання потоком:
$stream = Storage::disk('s3')->readStream('exports/big.csv');
while (($line = fgets($stream)) !== false) { /* ... */ }
Віддача користувачу:
// згенерований на льоту файл - без запису на диск
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
Order::query()->lazyById(1000)->each(fn ($o) => fputcsv($out, [$o->id, $o->total]));
fclose($out);
}, 'orders.csv');
// готовий файл у S3 - краще посилання, ніж проксі через PHP
return redirect(Storage::disk('s3')->temporaryUrl($path, now()->plus(minutes: 5)));
Що ще допомагає:
- важку обробку (конвертація відео, архівування) - у чергу, з окремим воркером і більшим таймаутом;
- завантаження великих файлів від клієнтів - напряму в сховище (
temporaryUploadUrl()); - для
localу Nginx -X-Accel-Redirect, щоб файл віддавав вебсервер, а PHP лише перевіряв права.
Cache Stampede (dogpile) - коли популярний ключ кешу протермінувався, і сотні одночасних запитів кидаються перераховувати важке значення одночасно, перевантажуючи БД.
Рішення в Laravel:
// блокування: лише один процес перераховує, інші чекають результат
$value = Cache::lock('report:lock', 10)->block(5, function () {
return Cache::remember('report', 3600, fn () => $this->heavyReport());
});
Інші стратегії:
Cache::flexible()(stale-while-revalidate) - віддає «протухле» значення, поки одне фонове оновлення його перераховує.- Розмазування TTL (jitter), щоб ключі не протухали одночасно.
- Прогрів кешу (cache warming) за розкладом, а не за запитом користувача.
Cache::lock() дає розподілене блокування: лише один процес на всіх серверах виконує ділянку коду одночасно.
$lock = Cache::lock("invoice:{$order->id}:generate", 30);
if ($lock->get()) {
try {
$order->generateInvoice();
} finally {
$lock->release();
}
}
Або коротше - з очікуванням до 5 секунд:
Cache::lock("invoice:{$order->id}:generate", 30)->block(5, function () use ($order) {
$order->generateInvoice();
});
Де це потрібно:
- подвійний клік чи повторний вебхук не має створити два рахунки;
- запланована команда, що запускається на кількох серверах;
- перерахунок дорогого кешу - лише один процес рахує, інші чекають.
Важливі деталі:
- Час життя блокування (30 с) - страховка, якщо процес упав і не звільнив його. Якщо робота може тривати довше, блокування закінчиться посередині, і зайде другий процес. Його подовжують через
refresh(). - Власник:
release()звільняє лише своє блокування. Щоб звільнити з іншого процесу (наприклад, у завданні черги), передають токен черезowner()іrestoreLock(). - Драйвер: потрібен спільний для всіх серверів - Redis, Memcached, database, DynamoDB.
fileблокує лише в межах одного сервера.
Блокування не замінює унікальних індексів у базі: індекс - остання лінія захисту, блокування лише зменшує кількість конфліктів.
Модель у кеші серіалізується цілком - з атрибутами, завантаженими зв'язками й службовим станом. Звідси кілька проблем.
1. Безпека десеріалізації. unserialize() даних, які можна підробити, - класичний шлях до виконання коду. У Laravel 13 конфігурація кешу має опцію serializable_classes, і за замовчуванням об'єкти з кешу не відновлюються. Щоб кешувати моделі, їхні класи (і класи вкладених колекцій) доводиться явно дозволити.
2. Застарілі дані. Закешована модель із завантаженими зв'язками - знімок усього дерева. Змінили автора - а в кеші поста він старий, і скинути треба вже не один ключ.
3. Зміна коду. Після деплою з новим атрибутом чи кастом старі серіалізовані об'єкти можуть відновитися в неконсистентному стані.
4. Розмір. Серіалізована модель зі зв'язками займає в рази більше пам'яті Redis, ніж потрібні поля.
Що кешують замість цього:
// ідентифікатори - а моделі дістають швидким запитом за ключем
$ids = Cache::remember('posts:popular', 600, fn () => Post::popular()->limit(10)->pluck('id')->all());
$posts = Post::with('author')->findMany($ids);
// або готові масиви для відображення
$menu = Cache::remember('menu', 3600, fn () => Category::query()->get(['name', 'slug'])->toArray());
Правило: кешують результат дорогого обчислення в найпростішій формі - скаляри, масиви, ID. Дорогим зазвичай є пошук і агрегація, а не завантаження рядка за первинним ключем.
Завдання можуть падати через тимчасові збої (мережа, rate limit) - потрібна стратегія повторів і обробки остаточних провалів.
class CallApi implements ShouldQueue
{
public int $tries = 5; // спроб
public int $maxExceptions = 2;
public int $timeout = 30;
// прогресивна затримка між спробами
public function backoff(): array
{
return [10, 30, 60]; // 10с, 30с, 60с...
}
public function failed(Throwable $e): void
{
// викликається після вичерпання спроб
}
}
- Остаточно провалені завдання осідають у таблиці
failed_jobs. php artisan queue:retry all- повторити,queue:flush- очистити.releaseAfter,WithoutOverlapping,RateLimitedmiddleware керують поведінкою.- Ідемпотентність (idempotency) обов'язкова - бо завдання може виконатися повторно.
Черги гарантують доставку «хоча б раз», а не «рівно раз»: завдання може виконатися повторно після таймауту, падіння воркера чи ручного queue:retry. Ідемпотентне завдання при повторі не робить шкоди - не списує гроші двічі й не шле другий лист.
Прийоми, від простого до надійного:
1. Перевірка стану перед дією:
public function handle(): void
{
if ($this->order->paid_at !== null) {
return; // уже оброблено
}
// ...
}
Сама по собі має вікно гонки: два воркери можуть одночасно побачити «не оплачено».
2. Обмеження бази як гарантія. Унікальний індекс на payments.order_id перетворює другу вставку на помилку, а не на дубль. Перевірку в коді можна обійти, обмеження в базі - ні.
3. Ключ ідемпотентності для зовнішніх API. Платіжні сервіси приймають заголовок на кшталт Idempotency-Key: повторний запит з тим самим ключем поверне перший результат, а не створить новий платіж.
4. Поділ на кроки. Не «спиши кошти й надішли лист» в одному завданні, а запис наміру в базу, списання, і лист окремим завданням після успіху - щоб повтор листа не тягнув повтор списання.
Плюс afterCommit(), щоб завдання не стартувало раніше, ніж закомічено транзакцію, яка його породила.
Це два різні механізми, і їх неправильне поєднання дає подвійне виконання.
--timeout(або#[Timeout]на завданні) - скільки воркер дозволяє завданню працювати, перш ніж убити процес. За замовчуванням 60 секунд.retry_afterуconfig/queue.php- через скільки секунд підключення вважає завдання загубленим і віддає його іншому воркеру.
Що буде, якщо retry_after менший за реальну тривалість: завдання на 120 секунд при retry_after = 90 на 91-й секунді отримає другий воркер. Перше ще працює - і тепер їх два.
Правило: --timeout має бути на кілька секунд меншим за retry_after. Тоді зависле завдання гарантовано вбивається до того, як його повторять.
Для довгих завдань:
- Підняти обидва значення для окремого підключення чи черги з довгими завданнями, не чіпаючи решту.
- Краще - розбити роботу на частини (
Bus::batch), щоб кожне завдання вкладалося в хвилину. #[FailOnTimeout]позначає завдання невдалим після таймауту замість повторів - для операцій, які небезпечно повторювати наосліп.
У SQS замість retry_after діє visibility timeout черги, і правило те саме.
Усі три борються з «зайвими» завданнями, але на різних етапах.
ShouldBeUnique - не пускає дубль у чергу. При dispatch() береться блокування за uniqueId(); поки перше завдання не виконане, друге просто не потрапить у чергу.
class RebuildSitemap implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 3600;
}
Підходить, коли результат від повтору не зміниться: перебудувати сайтмап двічі - те саме, що раз. ShouldBeUniqueUntilProcessing знімає блокування на початку виконання, а не наприкінці, тож нова зміна під час роботи вже може стати в чергу.
WithoutOverlapping - дублі можуть стояти в черзі, але не виконуються одночасно. Підходить, коли кожне завдання важливе, але паралельно вони зламають дані: два оновлення одного рахунку.
Дебаунс (#[DebounceFor], Laravel 13) - виконується лише останнє. Кожен новий виклик відсуває виконання, тож за серію змін товару індекс оновиться один раз - з найсвіжішими даними. ShouldBeUnique тут гірший: він лишив би перший виклик, і пізніші зміни загубилися б. Параметр maxWait не дає відкладати нескінченно.
Коротко: результат однаковий - unique; порядок важливий, паралель шкодить - overlapping; важливий лише останній стан - дебаунс.
Воркер queue:work завантажує застосунок один раз і тримає його в пам'яті. Після деплою він продовжує виконувати старий код, поки його не перезапустити.
Перезапуск:
php artisan queue:restart # звичайні воркери
php artisan horizon:terminate # якщо черги веде Horizon
Обидві команди просять воркери завершити поточне завдання й вийти, а Supervisor піднімає їх уже з новим кодом. queue:restart передає сигнал через кеш, тож усі сервери мають бачити спільне сховище кешу.
Що ламається тихіше - сумісність payload. У черзі можуть лежати завдання, серіалізовані старою версією класу:
- Перейменували чи видалили клас завдання - старі завдання впадуть, бо класу немає.
- Додали обов'язковий параметр конструктора - десеріалізоване старе завдання не матиме властивості.
Як робити безпечно:
- Зміни завдань - у два кроки: спершу код, що розуміє і старий, і новий формат, і лише після того, як черга спорожніє, прибрати старе.
- Для перейменування - залишити старий клас-обгортку, що делегує новому, на один реліз.
- Перед ризиковим деплоєм можна призупинити чергу (
queue:pause), дати воркерам добити поточні завдання й поновити після.
Contextual binding дозволяє віддавати різні реалізації одного інтерфейсу залежно від класу, що його запитує.
$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('local'));
$this->app->when(VideoController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('s3'));
Тобто PhotoController отримає локальний диск, VideoController - S3, хоча обидва просять Filesystem.
Споріднені можливості:
giveTagged()- впорснути всі сервіси з певним тегом.- Прив'язка примітивів:
->needs('$apiKey')->give(config('services.x.key')).
Корисно, коли одна абстракція має кілька конфігурацій у різних частинах застосунку.
У класичному PHP-FPM застосунок народжується й помирає з кожним запитом, тож singleton() фактично означає «один на запит». Під Octane чи у воркері черги процес живе годинами - і різниця стає критичною.
singleton()- один екземпляр на весь процес. Під Octane він переживає запит і обслуговує наступних користувачів.scoped()- один екземпляр на запит чи завдання. Контейнер скидає його на початку кожного.
$this->app->singleton(ExchangeRates::class); // незмінні дані - безпечно
$this->app->scoped(CurrentCart::class); // стан користувача - лише scoped
Класична помилка:
$this->app->singleton(CartService::class, fn ($app) => new CartService($app['request']->user()));
Під Octane перший користувач «приклеїться» до сервісу, і наступні побачать його кошик.
Правила:
- синглтон не тримає стану запиту: користувача,
Request, локаль, налаштування з сесії; - якщо сервісу потрібен запит -
scoped()або передавати запит у метод, а не в конструктор; - статичні властивості й кеш у пам'яті класу так само переживають запит.
Те саме стосується воркерів черги: завдання N бачить синглтони, створені під час завдання N-1.
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії