Питання на співбесіді з 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 для кількох проєктів без лімітів і окремих екземплярів - поширена причина аварій.
Ознаки, що воркери не встигають: задачі чекають у черзі хвилинами, листи підтвердження приходять із запізненням, 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 з лімітом, більше воркерів лише збільшить конкуренцію. Тоді потрібне обмеження частоти (
RateLimitedmiddleware) і пул з'єднань з базою.
Шифрування в 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), щоб бити по реальних вузьких місцях.
Шлях запиту:
public/index.php- єдина точка входу; підключає автозавантажувач Composer.- Створюється екземпляр застосунку (Service Container) із
bootstrap/app.php. - HTTP Kernel обробляє запит, завантажує Service Providers (
register→boot). - Запит проходить глобальні middleware (наприклад, обробка сесій, CSRF).
- Router зіставляє URL із маршрутом, виконуються middleware маршруту.
- Викликається контролер/замикання, формується Response.
- Відповідь проходить 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 і перезапускає воркери для безпеки.
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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії