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

Питання на співбесіді з 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):

  1. Додати нову колонку (nullable) - старий код працює.
  2. Задеплоїти код, що пише і в стару, і в нову.
  3. Перенести дані (фоновий job), перемкнути читання.
  4. Окремим релізом видалити стару колонку.

Практики:

  • Не покладатися на 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 можна прочитати застарілі дані.
  • Реплікація асинхронна → завжди закладайте можливе відставання реплік у логіці.

Докладніше в документації: Read/Write підключення

Файл, покладений у 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, RateLimited middleware керують поведінкою.
  • Ідемпотентність (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')).

Корисно, коли одна абстракція має кілька конфігурацій у різних частинах застосунку.

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

У класичному 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.

Докладніше в документації: Атрибут Scoped

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

Рівні
Junior 101 Middle 147 Senior 130

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