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

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

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

378 питань

Версіонування дозволяє розвивати API, не ламаючи наявних клієнтів. Стратегії:

URI versioning (найпоширеніше) - версія в шляху:

Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));

Header/Media-type versioning - Accept: application/vnd.app.v2+json. Чистіші URL, але складніше тестувати.

Практики:

  • Окремі неймспейси контролерів і API Resources на версію (V1\PostResource, V2\PostResource).
  • Бізнес-логіку виносити в спільні Action/Service, щоб не дублювати між версіями.
  • Політика deprecation: підтримувати стару версію певний строк, повертати заголовки Deprecation/Sunset.

Докладніше в документації: API Resources (версіонування)

Deadlock - дві транзакції взаємно блокують одна одну, чекаючи на ресурси, які тримає інша. СУБД виявляє це й «вбиває» одну з транзакцій.

Запобігання:

  • Єдиний порядок доступу до таблиць/рядків у всіх транзакціях.
  • Тримати транзакції короткими, блокувати якомога пізніше.
  • Правильні рівні ізоляції (не завищувати без потреби).

Обробка в Laravel - автоматичний повтор:

DB::transaction(function () {
    // ...
}, attempts: 3); // повторити при deadlock

Діагностика: SHOW ENGINE INNODB STATUS (MySQL), логи БД, моніторинг частоти deadlock. Інколи допомагає optimistic locking замість тривалих блокувань.

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

Serverless виконує код без керування серверами: провайдер (AWS Lambda) сам масштабує під навантаження й тарифікує за фактичні виклики.

Laravel Vapor - платформа для деплою Laravel на AWS Lambda + API Gateway, з керованими БД, чергами (SQS), кешем і CDN.

Особливості й обмеження:

  • Авто-масштабування до нуля й під пік; платиш лише за використання.
  • Cold start - затримка першого запиту після простою.
  • Файлова система ефемерна → файли лише в S3.
  • Обмеження часу виконання Lambda → довгі завданьі в черги.
  • Stateless за дизайном (сесії/кеш у Redis/DynamoDB).

Альтернатива - контейнери (ECS/Kubernetes), коли потрібен повний контроль або стабільні довготривалі процеси.

WebSocket - постійне двостороннє з'єднання поверх одного TCP, що дає реальний час без поллінгу.

У Laravel сервер WebSockets - Reverb (офіційний), Soketi або Pusher; події транслюються через Broadcasting, клієнт слухає через Echo.

Масштабування: коли інстансів WebSocket-сервера кілька, клієнти на різних інстансах не «бачать» одне одного. Рішення - Redis Pub/Sub як спільна шина: інстанс публікує повідомлення в Redis, усі інстанси отримують і розсилають своїм підключеним клієнтам.

client A ─ inst 1 ┐
                  ├─ Redis Pub/Sub ─┤
client B ─ inst 2 ┘

Окрема увага: авторизація private/presence-каналів, ліміти відкритих з'єднань, sticky sessions на балансувальнику.

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

2FA вимагає двох факторів: «що знаю» (пароль) + «що маю» (код із застосунку/SMS). Найпоширеніше - TOTP (Time-based One-Time Password) сумісно з Google Authenticator.

У Laravel найпростіше через Fortify, який має 2FA з коробки:

  • генерація секрету й QR-коду для прив'язки;
  • перевірка 6-значного коду при вході;
  • одноразові recovery codes на випадок втрати пристрою.
// Fortify вмикає features:
Features::twoFactorAuthentication(['confirm' => true]),

Під капотом - пакет pragmarx/google2fa. Важливо: зберігати секрет зашифрованим, давати recovery-коди, за бажанням «запам'ятати пристрій».

Докладніше в документації: Двофакторна автентифікація (Fortify)

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

Для сесій у браузері:

Route::middleware(['auth', 'auth.session'])->group(function () {
    // ...
});

Auth::logoutOtherDevices($request->input('current_password'));

logoutOtherDevices() перехешовує пароль, а middleware auth.session (AuthenticateSession) на кожному запиті порівнює хеш, збережений у сесії, з поточним. На інших пристроях хеші більше не збігаються - сесія закривається. Без auth.session на маршрутах виклик нічого не дає, і це найчастіша помилка.

Для API-токенів Sanctum механізм інший - токени відкликають явно:

$user->tokens()->delete();                          // усі
$user->tokens()->where('id', $tokenId)->delete();   // один пристрій

«Запам'ятати мене» скидається через новий remember_token.

Повна картина для користувача - сторінка «Активні сесії» з пристроями й можливістю закрити окрему. Для цього сесії мають бути в базі (драйвер database), щоб їх можна було перелічити й видалити за user_id.

Після зміни пароля варто також надіслати лист «Ваш пароль змінено» - якщо це зробив не власник, він дізнається одразу.

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

Паролі зберігають лише як повільний односторонній хеш - bcrypt чи argon2id через Hash::make().

  • Не шифрування: зашифроване відновлюється ключем, і витік ключа відкриває всі паролі.
  • Не SHA-256 чи MD5: вони швидкі, і на відеокарті перебирають мільярди варіантів за секунду.
  • Сіль генерується для кожного пароля окремо й зберігається в самому рядку хешу - окремо нічого не треба.

Посилення без скидання. Параметри хешування з часом піднімають - наприклад, BCRYPT_ROUNDS з 12 до 13. Старі хеші при цьому лишаються робочими, бо параметри записані в них самих.

Оновити їх можна лише в момент, коли відомий відкритий пароль, - при вході:

if (Hash::needsRehash($user->password)) {
    $user->update(['password' => Hash::make($plainPassword)]);
}

У Laravel це робиться автоматично: опція rehash_on_login у config/hashing.php увімкнена за замовчуванням, і після успішного входу хеш перезаписується з новими параметрами.

Перехід на інший алгоритм (bcrypt → argon2id) потребує ще одного кроку. За замовчуванням Hash::check() відхиляє хеш, створений іншим алгоритмом, і кидає RuntimeException - це захист від підміни хешу. На час переходу ставлять HASH_VERIFY=false, і тоді перезапис при вході поступово переведе активних користувачів. Неактивні лишаться на старому, і для них можна запланувати примусове скидання через рік-два, а потім повернути перевірку.

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

Колекції зручні настільки, що ними легко почати робити роботу бази - і заплатити памʼяттю та часом.

Типова помилка:

$active = Post::all()->where('is_published', true);        // вся таблиця в памʼять
$count  = Post::all()->count();                            // теж уся таблиця
$latest = Post::all()->sortByDesc('created_at')->take(5);  // і сортування в PHP

Кожен рядок витягує всі записи, щоб потім відкинути більшість.

Те саме в базі:

$active = Post::where('is_published', true)->get();
$count  = Post::count();
$latest = Post::latest()->limit(5)->get();

Різниця не косметична: база фільтрує за індексом і повертає стільки, скільки потрібно.

Як розрізняти. Методи Builder виконуються в SQL, методи Collection - у PHP. Межа проходить там, де стоїть get(), all() чи first(): усе після них - уже PHP.

Плутанину створює однакова назва: where() є і в білдера, і в колекції. Post::where(...) фільтрує в базі, Post::all()->where(...) - у памʼяті.

Коли колекція доречна:

  • Дані вже отримані, і потрібне ще одне групування чи перетворення - другий запит дорожчий.
  • Логіка не виражається в SQL: складна умова на PHP, робота з обʼєктами-значеннями.
  • Набір свідомо малий - десятки рядків, де різниці немає.

Коли ні: фільтрація, підрахунок, сортування й обмеження на таблиці, розмір якої росте. Це робота бази.

Проміжний варіант - lazy(): обхід пачками з синтаксисом колекції, коли обробити треба всі рядки, але тримати їх у памʼяті не можна.

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

Щоб логіка над набором моделей жила в одному місці, а не повторювалася в контролерах і шаблонах.

#[CollectedBy(OrderCollection::class)]
class Order extends Model {}

class OrderCollection extends Collection
{
    public function paid(): static
    {
        return $this->filter(fn (Order $order) => $order->isPaid());
    }

    public function totalInCents(): int
    {
        return $this->sum(fn (Order $order) => $order->total_cents);
    }
}

$orders = $customer->orders;            // OrderCollection
$orders->paid()->totalInCents();

Замість атрибута можна перевизначити newCollection() у моделі.

Де межа з іншими інструментами:

  • скоп (Order::paid()) - фільтрує в базі, коли рядків багато;
  • метод колекції - коли моделі вже завантажені й потрібно обчислення в пам'яті (кошик, рахунок, сторінка зі списком);
  • окремий клас (сервіс, звіт) - коли логіка залежить від зовнішніх сервісів чи її багато.

Переваги: типи - IDE знає, що повертає $customer->orders; тести - методи колекції тестуються без бази на make()-моделях.

Обережно з назвами, що збігаються з методами базової колекції, - перевизначення filter() чи sum() зламає код, який їх очікує.

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

Laravel Scout - драйверна абстракція повнотекстового пошуку. Додаєте трейт до моделі - і вона автоматично синхронізується з пошуковим індексом (Meilisearch, Algolia, Elasticsearch, навіть database).

class Post extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return ['title' => $this->title, 'body' => $this->body];
    }
}

Post::search('laravel queues')->paginate(15);
  • Індекс оновлюється на події моделі (краще - через чергу).
  • Початкове наповнення: php artisan scout:import "App\Models\Post".
  • Meilisearch дає швидкий typo-tolerant пошук «з коробки»; Elasticsearch - складніші аналітичні запити.

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

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

П'ять місць, де це видно:

1. Побічні ефекти не відкочуються. Лист, відправлений усередині транзакції, піде навіть після rollback - база відкотиться, а користувач уже отримав «Замовлення оформлено».

2. Завдання стартує раніше за commit. Воркер може взяти завдання до завершення транзакції й не знайти запису. Лікується afterCommit() на завданні або ShouldDispatchAfterCommit на події:

ProcessOrder::dispatch($order)->afterCommit();

3. Вкладені транзакції - не транзакції. Друга DB::transaction() усередині першої не створює окрему: використовуються savepoint-и, і зовнішній rollback скасує все одно все.

4. DDL не відкочується в MySQL. Schema::create() усередині транзакції робить неявний commit - міграції на MySQL атомарними не бувають, на відміну від PostgreSQL.

5. Довга транзакція тримає блокування. Звернення до зовнішнього API всередині транзакції означає, що рядки заблоковані на весь час мережевого очікування.

Практичне правило: усередині транзакції - лише запити до бази. Листи, черги, HTTP - після неї, за фактом успіху.

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

Класична гонка: два процеси читають баланс 100, кожен додає 50, обидва пишуть 150 - одне списання зникло. Читання й запис мають бути однією неподільною операцією.

Атомарний вираз - найдешевше, коли значення рахується з поточного:

Account::whereKey($id)->increment('balance', 50);
// UPDATE accounts SET balance = balance + 50 WHERE id = ?

База сама рахує нове значення, читати наперед не потрібно.

Песимістичне блокування - коли між читанням і записом є логіка:

DB::transaction(function () use ($id) {
    $account = Account::whereKey($id)->lockForUpdate()->first();

    if ($account->balance < 50) {
        throw new InsufficientFunds;
    }

    $account->decrement('balance', 50);
});

lockForUpdate() тримає рядок до кінця транзакції - інші процеси чекають. Обовʼязково всередині DB::transaction(), інакше блокування знімається одразу. Є ще sharedLock() - дозволяє читати, забороняє змінювати.

Оптимістичне блокування - без блокувань, через версію рядка:

$updated = Account::whereKey($id)
    ->where('version', $account->version)
    ->update(['balance' => $new, 'version' => $account->version + 1]);

if ($updated === 0) {
    // хтось випередив - перечитати й повторити
}

Дешевше під навантаженням, але вимагає обробки повтору.

Поза базою - Cache::lock() для операцій, що зачіпають не тільки БД:

Cache::lock('import:'.$id, 10)->block(5, function () {
    // виконується лише в одному процесі
});

Що обирати: просте збільшення - атомарний вираз; логіка між читанням і записом - lockForUpdate(); висока конкуренція за рідкісні конфлікти - оптимістичне.

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

Value Object - невеликий незмінний (immutable) об'єкт, що представляє концепцію домену й порівнюється за значенням, а не за ідентичністю (на відміну від Entity з id).

final class Money
{
    public function __construct(
        public readonly int $cents,
        public readonly string $currency,
    ) {}

    public function add(Money $other): self
    {
        return new self($this->cents + $other->cents, $this->currency);
    }
}

Переваги: інкапсуляція правил (валюта, валідація email), самодокументований код, безпека (незмінність). У Laravel VO зручно зберігати через Custom Casts, перетворюючи між колонкою БД та об'єктом.

CSP - HTTP-заголовок, що визначає, з яких джерел дозволено завантажувати ресурси (скрипти, стилі, зображення). Це потужний захист від XSS: навіть якщо зловмисник впровадить <script>, браузер не виконає його, якщо джерело не дозволене.

Content-Security-Policy: default-src 'self';
    script-src 'self' https://cdn.example.com;
    img-src 'self' data:;

У Laravel заголовок додають через middleware (вручну або пакетом на кшталт spatie/laravel-csp).

Практики:

  • Уникати 'unsafe-inline' - використовувати nonce для інлайн-скриптів.
  • Спершу режим report-only (Content-Security-Policy-Report-Only) зі збором звітів, щоб не зламати сайт.

За балансувальником чи CDN застосунок бачить з'єднання від проксі, а справжні дані клієнта приходять у заголовках X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host.

Без налаштування:

  • $request->ip() повертає IP балансувальника - обмеження частоти й журнали «бачать» одного клієнта;
  • $request->secure() - false, і Laravel генерує посилання з http://.

Довірені проксі:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: ['10.0.0.0/8']);
})

Laravel читає X-Forwarded-* лише від цих адрес. at: '*' безпечний, тільки коли застосунок недоступний напряму, - інакше будь-хто надішле X-Forwarded-For: 1.2.3.4 і обійде обмеження частоти чи журнали.

Довірені хости: абсолютні URL будуються з заголовка Host. Якщо вебсервер пропускає будь-який хост, запит на скидання пароля з підробленим Host дасть лист з посиланням на домен зловмисника - разом з токеном.

$middleware->trustHosts(at: ['^example\.com$'], subdomains: true);

Краще, коли вебсервер узагалі не приймає невідомі хости.

За Cloudflare реальний IP приходить ще й у CF-Connecting-IP; довіряти йому можна лише для запитів з діапазонів Cloudflare.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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