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

Питання на співбесіді з Laravel

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

378 питань

Laravel зчитує сесію на початку запиту, а записує цілком у кінці. Якщо два запити одного користувача виконуються паралельно, кожен працює зі своєю копією, і той, що завершиться останнім, перезапише зміни іншого.

Приклад:

Запит A (додати товар у кошик)   читає кошик [1]       записує [1, 2]
Запит B (додати інший товар)     читає кошик [1]       записує [1, 3]   <- товар 2 втрачено

Так буває з кількома AJAX-запитами одночасно, подвійним кліком, кількома вкладками. На відміну від стандартних файлових сесій PHP, драйвери Laravel (database, redis, file) не блокують сесію за замовчуванням.

Блокування сесії для конкретних маршрутів:

Route::post('/cart/items', AddToCartController::class)
    ->block($lockSeconds = 10, $waitSeconds = 10);
  • запит бере атомарне блокування для сесії (через кеш) і тримає його до $lockSeconds;
  • наступний запит цієї ж сесії до маршруту з block чекає до $waitSeconds, а якщо не дочекався - отримує LockTimeoutException;
  • запити виконуються послідовно, кожен бачить зміни попереднього.

Вимоги: драйвер кешу з атомарними блокуваннями (redis, memcached, database, dynamodb) і драйвер сесій не cookie.

Ціна: паралельність для одного користувача зникає - повільний запит затримує інші. Тому блокують лише маршрути, які змінюють сесію, а не весь застосунок.

Часто краще не тримати такі дані в сесії взагалі:

  • кошик, чернетки, налаштування - у базі з транзакціями чи атомарними операціями. Там конкурентність розв'язується надійніше й не залежить від сесії;
  • для лічильників - атомарний інкремент у Redis чи базі.

Як виявити проблему: «зникають» щойно додані дані, флеш-повідомлення показується не там, кошик втрачає товари при швидких кліках. Відтворюється одночасною відправкою двох запитів (наприклад, Promise.all з двох fetch).

Докладніше в документації: Блокування сесії

Усі значення з HTTP-запиту - рядки (чи масиви рядків). input('active') поверне "0", "false", "on" чи null, і if ($request->input('active')) для "false" дасть true. Laravel має методи, що одразу приводять значення до потрібного типу.

Типізовані методи:

$request->string('name')->trim();          // Stringable - зручні методи рядків
$request->integer('per_page', 15);         // int, за замовчуванням 15
$request->float('price');
$request->boolean('subscribe');            // true для "1", "true", "on", "yes"
$request->date('birthday');                // Carbon чи null
$request->date('starts_at', 'Y-m-d', 'Europe/Kyiv');
$request->enum('status', OrderStatus::class);          // case enum чи null
$request->enums('statuses', OrderStatus::class);       // масив case
$request->array('ids');
$request->collect('items');                // колекція

boolean() особливо корисний для чекбоксів: незазначений чекбокс взагалі не відправляється, і метод поверне false.

enum() повертає null для невідомого значення замість винятку - безпечно для фільтрів у рядку запиту.

Перевірки наявності:

Метод true, коли
has('name') ключ є в запиті (навіть порожній)
filled('name') ключ є і значення не порожнє
missing('name') ключа немає
isNotFilled('name') ключа немає чи значення порожнє
anyFilled(['email', 'phone']) хоча б одне заповнене

Умовна обробка без if:

$query = Order::query();

$request->whenFilled('status', fn (string $status) => $query->where('status', $status));
$request->whenHas('archived', fn () => $query->onlyTrashed());

Де це доречно, а де ні:

  • фільтри, сортування, пагінація з рядка запиту - типізовані методи з безпечними значеннями за замовчуванням ідеальні;
  • дані для збереження - через Form Request і validated(): правила boolean, date, Rule::enum() перевіряють і відхиляють неправильне значення з помилкою для користувача, а не тихо підставляють null;
  • у Form Request ті самі типізовані методи доступні через $this->safe(): $request->safe()->integer('quantity').

Докладніше в документації: Типізовані вхідні значення

Інколи дані треба нормалізувати до валідації: прибрати пробіли з телефону, згенерувати slug з назви, привести email до нижнього регістру.

merge і mergeIfMissing:

$request->merge(['slug' => Str::slug($request->input('title'))]);
$request->mergeIfMissing(['locale' => 'uk']);   // лише якщо поля немає

У Form Request - prepareForValidation():

final class StorePostRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => Str::slug($this->input('slug') ?: $this->input('title')),
            'phone' => preg_replace('/\D+/', '', (string) $this->input('phone')),
        ]);
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'alpha_dash', Rule::unique('posts')],
            'phone' => ['nullable', 'digits_between:10,12'],
        ];
    }
}

Правила перевіряють уже нормалізоване значення: унікальність slug перевіряється для того slug, який справді буде збережено.

Після валідації - passedValidation() - для перетворень, які не мають впливати на перевірку (наприклад, хешування).

Глобальна нормалізація вже є: middleware TrimStrings обрізає пробіли, а ConvertEmptyStringsToNull перетворює порожні рядки на null. Через це nullable правила й порожні поля працюють очікувано. Виключити поля (наприклад, пароль) можна в bootstrap/app.php.

Чому обережно:

  • merge змінює спільний об'єкт запиту. Після нього змінене значення бачать усі: middleware, що виконуються далі, слухачі, логування. Нормалізація в одному контролері раптово впливає на інший код;
  • логіка ховається: читач контролера не бачить, що phone уже не те, що надіслав клієнт;
  • не підміняйте значення, які мають бути помилкою. Якщо status невідомий, правильна відповідь - помилка валідації, а не тихе merge(['status' => 'draft']);
  • не додавайте через merge дані, що не повинні приходити від клієнта (user_id, роль), щоб «пройти валідацію». Такі поля встановлюються явно при збереженні, а не потрапляють у вхідні дані.

Альтернатива для обчислюваних полів: не змінювати запит, а додати значення при збереженні: Post::create([...$request->validated(), 'user_id' => $request->user()->id]).

Докладніше в документації: Додавання вхідних даних

Скільки номерів показувати навколо поточної сторінки:

{{ $users->onEachSide(1)->links() }}

За замовчуванням - по три з кожного боку. На мобільних onEachSide(1) робить навігацію компактною.

Вбудовані шаблони: за замовчуванням Laravel генерує розмітку для Tailwind CSS. Для Bootstrap - один раз у провайдері:

// AppServiceProvider::boot()
Paginator::useBootstrapFive();

Власний шаблон для всього застосунку:

php artisan vendor:publish --tag=laravel-pagination

Шаблони копіюються в resources/views/vendor/pagination, і їх можна змінювати. Або вказати свої за замовчуванням:

Paginator::defaultView('pagination.compact');
Paginator::defaultSimpleView('pagination.simple-compact');

Окремий шаблон для конкретного місця:

{{ $users->links('pagination.admin') }}

Параметри в посиланнях:

$users = User::paginate(20)->withQueryString();          // зберегти поточні фільтри
$users = User::paginate(20)->appends(['sort' => 'name']); // додати свої параметри
$users = User::paginate(20)->fragment('users');           // #users наприкінці посилань
$users = User::paginate(20)->withPath('/admin/users');    // інший базовий шлях

fragment корисний, коли список - не на початку сторінки: після переходу браузер прокручує до потрібного блоку.

Кілька пагінаторів на одній сторінці - різні назви параметра, інакше обидва реагують на ?page=:

$posts = Post::paginate(10, pageName: 'posts');
$comments = Comment::paginate(10, pageName: 'comments');

Доступні дані в шаблоні: $paginator->currentPage(), lastPage(), total(), firstItem(), lastItem(), hasMorePages(), previousPageUrl(), nextPageUrl() - з них можна зібрати навігацію будь-якого вигляду.

SEO й доступність у власному шаблоні: справжні посилання <a href> (а не кнопки з JavaScript), rel="prev"/rel="next", aria-current="page" для поточної сторінки, aria-label для стрілок.

Докладніше в документації: Налаштування вигляду пагінації

Redis у Laravel часто виконує одразу кілька ролей:

Роль Конфігурація Що станеться, якщо дані зникнуть
кеш CACHE_STORE=redis нічого страшного - перерахується
черги QUEUE_CONNECTION=redis втрачено задачі: листи не підуть, оплати не обробляться
сесії SESSION_DRIVER=redis користувачів розлогінить
блокування Cache::lock, withoutOverlapping, ShouldBeUnique дублювання задач на короткий час
обмеження частоти RateLimiter ліміти скинуться
Horizon, Pulse, broadcasting метрики, pub/sub залежить від ролі

Проблема спільного Redis - різна цінність даних. Кеш можна втратити, черги - ні. А налаштування в Redis одне на весь екземпляр.

Витіснення при нестачі пам'яті (maxmemory-policy):

  • для кешу логічна allkeys-lru - видаляти найстаріші ключі;
  • але ця політика видаляє будь-які ключі, включно з задачами в черзі й сесіями. Під навантаженням, коли кеш заповнив пам'ять, Redis тихо видаляє задачі;
  • для черг потрібна noeviction - тоді при нестачі пам'яті Redis відмовляє в записі, і помилку видно.

cache:clear очищує всю базу Redis (FLUSHDB), яку використовує кеш. Якщо кеш і черги в одній базі, скидання кешу при деплої видаляє черги.

Тому Laravel за замовчуванням розділяє з'єднання в config/database.php:

'redis' => [
    'default' => [..., 'database' => env('REDIS_DB', '0')],        // черги, сесії
    'cache'   => [..., 'database' => env('REDIS_CACHE_DB', '1')],  // кеш
],

Окремі номери баз захищають від FLUSHDB, але не від витіснення - політика пам'яті спільна для екземпляра.

Надійне розділення на продакшені:

  • окремі екземпляри Redis для кешу (з allkeys-lru і лімітом пам'яті) і для черг та сесій (noeviction, збереження на диск через AOF);
  • з'єднання для черги: REDIS_QUEUE_CONNECTION, для сесій - SESSION_CONNECTION, для блокувань кешу - REDIS_CACHE_LOCK_CONNECTION;
  • моніторинг пам'яті (INFO memory, used_memory, evicted_keys) - зростання evicted_keys сигналізує, що кеш тисне на ліміт.

Ще одна пастка: один «сусідній» застосунок на тому самому Redis може заповнити пам'ять і зламати всіх. Спільний Redis для кількох проєктів без лімітів і окремих екземплярів - поширена причина аварій.

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

Ознаки, що воркери не встигають: задачі чекають у черзі хвилинами, листи підтвердження приходять із запізненням, Horizon повідомляє про довге очікування (LongWaitDetected).

1. Більше воркерів. Найпростіший крок - запустити кілька процесів queue:work (через Supervisor numprocs, кілька контейнерів чи Horizon maxProcesses). Кожен воркер обробляє одну задачу за раз, тож N воркерів - до N задач паралельно.

Межа - ресурси: кожен воркер - окремий PHP-процес з власною пам'яттю, і кожен тримає з'єднання з базою.

2. Розділити черги за пріоритетом:

SendPasswordReset::dispatch($user)->onQueue('high');
GenerateReport::dispatch($report)->onQueue('low');
php artisan queue:work --queue=high,default,low

Воркер спершу бере задачі з high, і масовий імпорт у low не затримує скидання пароля.

3. Окремі воркери для окремих черг. Пріоритети не рятують, якщо повільні задачі займають усіх воркерів. Тоді виділяють окремі групи:

  • 2 воркери лише для high;
  • 6 воркерів для default;
  • 2 воркери для low з більшим --timeout і лімітом пам'яті.

4. Horizon - балансування автоматично:

'supervisor-1' => [
    'queue' => ['high', 'default', 'low'],
    'balance' => 'auto',
    'autoScalingStrategy' => 'time',
    'minProcesses' => 1,
    'maxProcesses' => 20,
],

Horizon перерозподіляє процеси між чергами залежно від навантаження і показує час очікування й пропускну здатність кожної черги.

5. Окремі сервери для воркерів. Воркери не мають бути на вебсерверах: важкі задачі (обробка відео, PDF) не повинні впливати на швидкість сторінок. Окремі сервери можна масштабувати незалежно.

Перш ніж додавати воркерів, перевірте задачі:

  • чому задача повільна - N+1 у задачі, синхронні виклики зовнішніх API, обробка файлів у пам'яті;
  • чи можна розбити - одна задача «надіслати розсилку 50 000 листів» гірша за 50 000 маленьких (чи пакет Bus::batch);
  • вузьке місце може бути не у воркерах: якщо всі задачі ходять у базу чи сторонній API з лімітом, більше воркерів лише збільшить конкуренцію. Тоді потрібне обмеження частоти (RateLimited middleware) і пул з'єднань з базою.

Докладніше в документації: Пріоритети черг

Шифрування в Laravel - симетричне, з ключем APP_KEY, алгоритм за замовчуванням AES-256-CBC з підписом HMAC (чи AES-GCM з вбудованою автентифікацією). Зашифроване значення не можна ні прочитати, ні непомітно змінити без ключа.

use Illuminate\Support\Facades\Crypt;

$token = Crypt::encryptString($apiToken);
$apiToken = Crypt::decryptString($token);

encryptString чи encrypt:

  • encryptString() / decryptString() - шифрує рядок як є;
  • encrypt() / decrypt() (і хелпери encrypt(), decrypt()) спершу серіалізують значення через serialize() - можна шифрувати масиви й об'єкти, але при розшифруванні викликається unserialize(). Для рядків краще encryptString.

Результат - base64 від JSON з полями iv, value, mac (чи tag для GCM). Він довший за вихідний рядок: для колонки бази потрібен text, а не короткий varchar.

Помилки розшифрування:

try {
    $value = Crypt::decryptString($payload);
} catch (DecryptException $e) {
    // значення пошкоджено, підроблено чи зашифровано іншим ключем
}

Ротація ключа. Якщо просто замінити APP_KEY, усе зашифроване старим ключем стане нечитабельним: cookie, сесії, поля з cast encrypted, збережені токени. Для плавної заміни:

APP_KEY="base64:NEW..."
APP_PREVIOUS_KEYS="base64:OLD..."
  • шифрування - завжди новим ключем;
  • розшифрування - спершу новим, потім по черзі старими з APP_PREVIOUS_KEYS;
  • користувачі не розлогінюються, дані читаються.

Але старі ключі треба колись прибрати: дані в базі, зашифровані старим ключем, лишаються такими, доки їх не перезаписати. Після ротації - команда чи задача, що читає й зберігає ці записи заново (вони перешифровуються новим ключем), і лише потім видалення старого ключа з APP_PREVIOUS_KEYS.

Що не шифрувати через Crypt:

  • паролі - їх хешують (Hash::make), а не шифрують: розшифрувати пароль неможливо має бути навіть для вас;
  • поля, за якими шукаєте: зашифроване значення щоразу різне (випадковий IV), тому where('email', ...) не спрацює. Для пошуку - окрема колонка з хешем (HMAC) значення.

Ключ - єдиний секрет усієї схеми. Витік APP_KEY разом з дампом бази - це витік усіх зашифрованих даних.

Докладніше в документації: Плавна ротація ключів шифрування

Фасад Process - обгортка над компонентом Symfony Process для запуску зовнішніх програм: git, ffmpeg, pg_dump, скриптів. Він замінює exec() і shell_exec() зручним API з тайм-аутами й тестуванням.

Запуск і результат:

use Illuminate\Support\Facades\Process;

$result = Process::run(['git', 'log', '--oneline', '-5']);

$result->successful();   // код завершення 0
$result->output();       // stdout
$result->errorOutput();  // stderr
$result->exitCode();

Масив аргументів замість рядка - головне правило безпеки. З масивом кожен аргумент передається окремо, без участі shell, і ін'єкція команд неможлива:

Process::run(['convert', $uploadedPath, '-resize', '800x', $outputPath]);   // безпечно
Process::run("convert {$uploadedPath} -resize 800x {$outputPath}");         // ін'єкція через назву файлу

Параметри:

Process::path(storage_path('exports'))
    ->timeout(120)              // за замовчуванням 60 секунд
    ->idleTimeout(30)           // без виводу довше - зупинити
    ->env(['PGPASSWORD' => $password])
    ->input($csv)
    ->run(['psql', '-c', '\copy items from stdin csv']);

Process::run($command)->throw();   // виняток при ненульовому коді

Асинхронно й паралельно:

$process = Process::start(['ffmpeg', '-i', $in, $out]);
// ... інша робота ...
$result = $process->wait();

[$first, $second] = Process::concurrently(function (Pool $pool) {
    $pool->path(base_path())->command(['npm', 'run', 'lint']);
    $pool->path(base_path())->command(['vendor/bin/pint', '--test']);
});

Тестування - без справжнього запуску:

Process::fake([
    '*ffmpeg*' => Process::result(output: 'done'),
    '*git*' => Process::result(errorOutput: 'fatal', exitCode: 128),
]);

// ... виклик коду ...

Process::assertRan(fn (PendingProcess $process) => $process->command[0] === 'ffmpeg');

Шаблони порівнюються з повним командним рядком, де аргументи масиву взяті в лапки, тому зручні шаблони з * з обох боків. Process::preventStrayProcesses() змушує тест падати, якщо запущено процес, для якого немає підробки, - тести ніколи не запустять справжній rm чи git push.

Де запускати: довгі процеси (конвертація відео, бекапи) - у черзі, а не у вебзапиті; тайм-аут процесу має бути меншим за тайм-аут задачі.

Докладніше в документації: Процеси

Оптимізація йде кількома шарами:

База даних

  • Усунення N+1 (eager loading with()), правильні індекси, аналіз через EXPLAIN.
  • Read/write репліки, кешування важких запитів.

Кешування

  • Cache::remember() для дорогих обчислень; повне кешування сторінок/фрагментів.
  • php artisan optimize (config/route/view/event cache), OPcache.

Фонова робота

  • Винесення повільних завдань (email, обробка медіа, виклики API) у черги.

Інфраструктура

  • Laravel Octane (Swoole/FrankenPHP) тримає застосунок у пам'яті.
  • CDN для статики, горизонтальне масштабування за load balancer, спільні сесії/кеш у Redis.

Перед оптимізацією - профілювання (Telescope, Clockwork, Debugbar), щоб бити по реальних вузьких місцях.

Докладніше в документації: Оптимізація для деплою

Шлях запиту:

  1. public/index.php - єдина точка входу; підключає автозавантажувач Composer.
  2. Створюється екземпляр застосунку (Service Container) із bootstrap/app.php.
  3. HTTP Kernel обробляє запит, завантажує Service Providers (register → boot).
  4. Запит проходить глобальні middleware (наприклад, обробка сесій, CSRF).
  5. Router зіставляє URL із маршрутом, виконуються middleware маршруту.
  6. Викликається контролер/замикання, формується Response.
  7. Відповідь проходить middleware у зворотному порядку й повертається клієнту; виконується terminate().

Ключова ідея: контейнер і провайдери бутстрапять застосунок, а middleware утворюють «цибулю» навколо обробки запиту.

Докладніше в документації: Життєвий цикл запиту

  • CQRS (Command Query Responsibility Segregation) розділяє запис (Commands, що змінюють стан) і читання (Queries). Read-модель можна оптимізувати окремо (денормалізовані проєкції, окрема БД).
  • Event Sourcing зберігає не поточний стан, а послідовність подій; поточний стан відновлюється їх відтворенням. Дає повний аудит і «подорож у часі».
// концептуально
$aggregate->retrieve($uuid)
    ->placeOrder($data) // emit OrderPlaced
    ->persist(); // зберегти подію

У Laravel зазвичай через пакет spatie/laravel-event-sourcing (aggregates, projectors, reactors). Застосовувати варто там, де критичні аудит і складна доменна логіка - це додає суттєву складність, тож не для типового CRUD.

Octane запускає застосунок через high-performance сервери (Swoole, FrankenPHP, RoadRunner): фреймворк бутстрапиться один раз і тримається в пам'яті, обслуговуючи наступні запити без повторної ініціалізації.

php artisan octane:start --server=frankenphp

Це усуває оверхед завантаження на кожному запиті й дає кратний приріст RPS.

Підводні камені: оскільки процес довготривалий, треба уникати витоків стану між запитами - статичні властивості, синглтони з накопиченим станом, глобальні змінні можуть «протікати» від запиту до запиту. Octane надає хуки flush і перезапускає воркери для безпеки.

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

Pessimistic locking блокує рядок у БД до завершення транзакції - інші транзакції чекають.

DB::transaction(function () {
    $account = Account::lockForUpdate()->find($id); // блокування на запис
    $account->balance -= 100;
    $account->save();
});

sharedLock() - блокування на читання.

Optimistic locking не блокує, а перевіряє версію/updated_at перед записом; якщо хтось уже змінив рядок - оновлення відхиляється, операцію повторюють.

UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
  • Pessimistic - для високої конкуренції за тими ж рядками (платежі, склад).
  • Optimistic - коли конфлікти рідкісні; масштабується краще, бо не тримає блокувань.

Докладніше в документації: Песимістичні блокування

Два основні підходи:

Single Database (shared schema) - усі орендарі в одній БД, розділення за tenant_id у кожній таблиці. Ізоляція забезпечується global scope, що автоматично додає where tenant_id = ?.

  • Плюси: просто й дешево. Мінуси: ризик витоку даних при помилці у scope.

Multi Database - окрема БД (або схема) на орендаря, динамічне перемикання з'єднання за поточним tenant.

  • Плюси: сильна ізоляція, легше бекапити/масштабувати окремого клієнта. Мінуси: складніші міграції (на кожну БД).
Tenancy::initialize($tenant); // перемкнути конфіг з'єднання/кеш/файли

Популярний пакет - stancl/tenancy. Вибір залежить від вимог до ізоляції та масштабу.

Гексагональна архітектура ізолює ядро бізнес-логіки від зовнішнього світу.

  • Ports - інтерфейси, через які ядро спілкується зі світом (PaymentGateway, UserRepository).
  • Adapters - конкретні реалізації портів (StripeAdapter, EloquentUserRepository, HTTP-контролер).
[ HTTP / CLI / Queue ]  →  Port  →  [ Domain Core ]  →  Port  →  [ DB / API / Mail ]
        (adapters)                   (бізнес-логіка)              (adapters)

Ядро не знає про Laravel, БД чи HTTP - воно залежить лише від абстракцій. Перевага: домен тестується ізольовано, зовнішні залежності легко підмінювати. У Laravel порти біндять до адаптерів через Service Container.

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

Рівні
Junior 101 Middle 147 Senior 130

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