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

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

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

378 питань

Query Builder Eloquent
Повертає stdClass / масиви моделі
Рівень близько до SQL ORM поверх QB
Зв'язки, події, касти ні так
Оверхед мінімальний невеликий
// Query Builder
DB::table('users')->where('active', 1)->get();

// Eloquent
User::where('active', 1)->get();

Eloquent виразніший і зручніший для бізнес-логіки. Для важких масових операцій (мільйони рядків, складні агрегати) інколи свідомо обирають чистий Query Builder заради швидкості та меншого споживання пам'яті.

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

У belongsToMany проміжна (pivot) таблиця зберігає зв'язки. Додаткові стовпці на ній оголошують через withPivot():

public function roles(): BelongsToMany
{
    return $this->belongsToMany(Role::class)
        ->withPivot('assigned_at', 'is_primary')
        ->withTimestamps();
}

Доступ і керування:

$user->roles->first()->pivot->assigned_at;  // читання pivot-даних

$user->roles()->attach($roleId, ['is_primary' => true]); // додати
$user->roles()->detach($roleId); // прибрати
$user->roles()->sync([1, 2, 3]);// привести до набору
$user->roles()->updateExistingPivot($roleId, [...]); // оновити pivot

Для окремої моделі pivot використовують ->using(RoleUser::class).

Докладніше в документації: Many To Many зв’язки

Через withCount() і схожі методи - вони додають до основного запиту підзапит, і число приходить окремим атрибутом.

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;   // без завантаження коментарів
}

Чому не $post->comments->count(): це завантажить усі коментарі кожного поста в пам'ять, а без with() ще й дасть N+1 запитів - лише заради одного числа.

Родина методів:

Post::withCount(['comments', 'comments as approved_count' => fn ($q) => $q->where('approved', true)])
    ->withExists('likes')          // likes_exists: true / false
    ->withSum('orders', 'total')   // orders_sum_total
    ->withMax('comments', 'created_at')
    ->get();

Фільтр, а не підрахунок, - це has() і whereHas():

Post::has('comments', '>=', 10)->get();
Post::whereHas('comments', fn ($q) => $q->where('approved', true))->get();

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

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

Через hasOne(...)->latestOfMany() - один запис із багатьох, вибраний за правилом.

class User extends Model
{
    public function latestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->latestOfMany();
    }

    public function largestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->ofMany('total', 'max');
    }
}

$users = User::with('latestOrder')->get();

Чому не $user->orders()->latest()->first(): у циклі по користувачах це запит на кожного - N+1. А latestOfMany() - справжній зв'язок: його можна жадібно завантажити одним запитом для всіх користувачів, і Eloquent сам збудує підзапит з MAX(id) на кожного.

Що ще вміє:

  • oldestOfMany() - перший запис;
  • складніші правила: «найновіша опублікована ціна», де спершу береться максимум дати, а нічия розв'язується за id:
public function currentPricing(): HasOne
{
    return $this->hasOne(Price::class)->ofMany(
        ['published_at' => 'max', 'id' => 'max'],
        fn ($query) => $query->where('published_at', '<', now()),
    );
}

Існує й перетворення готового hasMany: $this->orders()->one()->latestOfMany().

Докладніше в документації: Has one of many

hasManyThrough дістає записи через проміжну модель - коли прямого зовнішнього ключа немає.

Приклад: проєкт має середовища, середовища мають деплої. Потрібні всі деплої проєкту.

projects        environments          deployments
id              id, project_id        id, environment_id
class Project extends Model
{
    public function deployments(): HasManyThrough
    {
        return $this->hasManyThrough(Deployment::class, Environment::class);
    }
}

$project->deployments;   // один запит з JOIN через environments

Чому не вручну: $project->environments->flatMap->deployments завантажить усі середовища, а потім деплої окремими запитами. Зв'язок робить один запит і, як будь-який зв'язок, підтримує with(), withCount() і whereHas().

Порядок ключів, якщо імена нестандартні, - найчастіше джерело плутанини:

return $this->hasManyThrough(
    Deployment::class,
    Environment::class,
    'project_id',      // ключ у environments
    'environment_id',  // ключ у deployments
    'id',              // локальний ключ у projects
    'id',              // локальний ключ у environments
);

Читабельніша альтернатива - через уже наявні зв'язки: $this->through('environments')->has('deployments'). Для одного запису є hasOneThrough.

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

Логування в Laravel побудоване на Monolog; канали налаштовуються в config/logging.php.

Log::info('Замовлення створено', ['id' => $order->id]);
Log::channel('slack')->critical('Платіж не пройшов');

Типи каналів: single (один файл), daily (ротація по днях), slack, papertrail, stderr, а також stack - який пише одразу в кілька:

'stack' => ['driver' => 'stack', 'channels' => ['daily', 'slack']],

Практики: рівні (debug…emergency), структуроване логування з контекстом, маскування PII, окремий канал для критичних подій у Slack/Sentry. У проді LOG_LEVEL зазвичай warning+.

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

Порядок кроків важливіший за їхній перелік: та сама команда, виконана не там, ламає деплой.

Типова послідовність:

composer install --no-dev --optimize-autoloader
npm ci && npm run build

php artisan migrate --force

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

php artisan queue:restart

Чому саме так:

  • Кешування - після викладення коду. Інакше в кеш потрапить попередня версія конфігурації й маршрутів.
  • --force для міграцій потрібен, бо в проді Artisan питає підтвердження, а деплой неінтерактивний. Це стосується лише migrate; migrate:fresh у проді не запускають ніколи.
  • queue:restart обовʼязковий. Воркери - довгоживучі процеси: вони тримають у памʼяті стару версію коду й працюватимуть з нею, поки їх не перезапустити. Це найчастіша причина «код виклали, а черга робить по-старому».

Про що забувають:

  • Порядок міграції та коду. Видалення колонки у тому ж випуску, що й код, який її ще читає, ламає застосунок у проміжку між кроками. Такі зміни розводять на два випуски.
  • storage:link після першого розгортання, інакше публічні файли не віддаються.
  • Права на storage і bootstrap/cache - веб-сервер має писати в обидві.
  • php artisan down дає режим обслуговування, але з ним застосунок недоступний; безпростійний деплой роблять перемиканням симлінка на новий реліз.

Готові рішення - Envoyer, Deployer, Laravel Cloud - роблять саме це: викладають реліз поруч і перемикають симлінк, коли він готовий.

Докладніше в документації: Розгортання

Воркер черги, Horizon, Octane, Reverb, schedule:work завантажують код один раз і тримають його в пам'яті. Після деплою вони продовжують виконувати старий код, доки їх не перезапустити.

Команда в Laravel 13:

php artisan reload

Вона завершує ці процеси, а підняти їх знову має монітор процесів: Supervisor, systemd чи оркестратор. Без монітора процеси просто зупиняться.

Окремі команди для конкретних сервісів:

  • php artisan queue:restart - воркери завершать поточне завдання й вийдуть;
  • php artisan horizon:terminate - те саме для Horizon;
  • php artisan octane:reload.

Приклад Supervisor:

[program:laravel-worker]
command=php /var/www/app/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=2
stopwaitsecs=3600

stopwaitsecs має бути більшим за найдовше завдання, інакше Supervisor уб'є воркер посеред роботи.

Порядок деплою: новий код і optimize → міграції → reload. Воркер, що стартує зі старою схемою й новим кодом (чи навпаки), - типове джерело помилок одразу після релізу.

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

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

services:
  app:       { image: app:1.4.2, command: frankenphp run }
  worker:    { image: app:1.4.2, command: php artisan queue:work --max-time=3600 }
  scheduler: { image: app:1.4.2, command: php artisan schedule:work }

Що робити під час збирання образу: composer install --no-dev --optimize-autoloader, збирання ассетів, view:cache, event:cache, route:cache.

Що НЕ робити під час збирання: config:cache. У кеш потрапить оточення збирання, а не продакшену - конфігурацію кешують на старті контейнера, коли змінні оточення вже є.

Стан - назовні:

  • сесії, кеш, черги - Redis чи база, а не файли контейнера;
  • завантаження - S3-сумісне сховище чи том;
  • журнали - у stdout/stderr (канал stderr), їх збирає платформа.

Інше:

  • права на storage і bootstrap/cache для користувача PHP-процесу;
  • міграції - окремим кроком деплою, не в старті кожного контейнера;
  • пінити версію базового образу: плаваючий тег може принести нову версію PHP чи бібліотек без вашого відома;
  • /up - для перевірки стану контейнера.

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

Signed URL - посилання з криптографічним підписом у query-рядку. Якщо хтось змінить URL, підпис стане недійсним - підробка неможлива без APP_KEY.

// згенерувати
URL::signedRoute('unsubscribe', ['user' => $id]);
URL::temporarySignedRoute('download', now()->addMinutes(30), ['file' => $id]);
// перевірити (middleware або вручну)
Route::get('/download/{file}', ...)->middleware('signed');

Застосування: посилання-відписки в листах, тимчасові посилання на скачування, підтвердження email - там, де потрібен захищений доступ без автентифікації. temporarySignedRoute додає термін дії.

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

APP_KEY - ключ, яким Laravel шифрує й підписує дані.

Що від нього залежить:

  • шифровані cookie, зокрема сесійна;
  • Crypt::encrypt() і каст encrypted у моделях;
  • підписані URL (URL::signedRoute());
  • зашифровані завдання черги (ShouldBeEncrypted).

Паролі від нього не залежать - вони хешуються bcrypt чи argon2, а не шифруються.

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

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

Якщо ключ витік - зловмисник може підробляти cookie й підписані URL, розшифрувати дані з бази. Тому:

  1. згенерувати новий ключ, старий - тимчасово в APP_PREVIOUS_KEYS;
  2. перешифрувати дані з кастом encrypted новим ключем;
  3. прибрати старий ключ з APP_PREVIOUS_KEYS.

Ключ не комітять у репозиторій і не логують; для .env у репозиторії є php artisan env:encrypt.

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

Завантаження файлів - одна з найчастіших дір: файл може виявитися скриптом, перезаписати чужий файл чи покласти сервер розміром.

Валідація:

$request->validate([
    'avatar' => ['required', File::image()->max('2mb')->dimensions(Rule::dimensions()->maxWidth(4000))],
    'contract' => ['required', 'file', 'mimes:pdf', 'max:10240'],
]);
  • mimes визначає тип за вмістом, а не за назвою;
  • extensions перевіряє розширення, яке дав користувач - корисно разом з mimes;
  • max - у кілобайтах; ліміт розміру тіла запиту ще й на вебсервері.

Збереження:

  • ім'я - hashName() (випадкове) і розширення - extension() (за вмістом). getClientOriginalName() можна підробити, зокрема з ../ у шляху;
  • приватні документи - на приватний диск, віддавати через контролер з перевіркою прав чи temporaryUrl();
  • публічний диск ніколи не має виконувати PHP: файли віддає вебсервер як статику.

Обробка:

  • зображення перекодувати (Laravel 13: $request->image('avatar')->toWebp()) - нове кодування зазвичай відкидає вбудований у файл сторонній вміст і метадані на кшталт геолокації (результат варто перевірити для свого драйвера);
  • великі файли обробляти в черзі.

Оригінальне ім'я файлу, якщо воно потрібне людям, зберігають окремо в базі й екранують при виводі.

Докладніше в документації: Інформація про завантажений файл

Через властивість $signature: аргументи - у фігурних дужках, опції - з --.

protected $signature = 'reports:send
                        {user : ID користувача}
                        {period=month : day, week або month}
                        {--queue : Поставити надсилання в чергу}
                        {--format=pdf : Формат файлу}
                        {--tag=* : Можна передати кілька разів}';
php artisan reports:send 7 week --queue --format=csv --tag=sales --tag=q3

Що означають позначки:

  • {user} - обов'язковий аргумент, {user?} - необов'язковий, {period=month} - зі значенням за замовчуванням;
  • {user*} - масив аргументів;
  • {--queue} - перемикач (true, якщо передали), {--format=} чи {--format=pdf} - опція зі значенням;
  • {--tag=*} - масив значень опції;
  • текст після : - опис для php artisan help reports:send.

Читання в handle():

$userId = $this->argument('user');
$asCsv  = $this->option('format') === 'csv';

Залежності беруть параметрами handle() - контейнер їх впровадить. Для обов'язкових аргументів, які зручно запитати інтерактивно, є інтерфейс PromptsForMissingInput.

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

$exitCode = Artisan::call('reports:send', ['user' => 7, '--queue' => true]);
$output   = Artisan::output();

Artisan::queue('reports:send', ['user' => 7])->onQueue('reports');   // у черзі

Artisan::call() виконує команду синхронно в поточному процесі й повертає код виходу. Artisan::queue() ставить виконання в чергу.

З іншої команди - $this->call('cache:clear') або $this->callSilently() без виводу.

Коли це доречно:

  • адмін-кнопка «перебудувати індекс», що запускає наявну команду;
  • команда-оркестратор, яка по черзі викликає інші;
  • тести консольних команд.

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

Обережно з Artisan::call() у запиті: важка команда заблокує запит на весь час виконання. Для довгого - черга.

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

$response = Http::connectTimeout(3)
    ->timeout(10)
    ->retry([200, 500, 1000], when: fn (Throwable $e) => $e instanceof ConnectionException
        || ($e instanceof RequestException && $e->response->serverError()))
    ->get($url);

Таймаути:

  • connectTimeout() - скільки чекати з'єднання (за замовчуванням 10 с);
  • timeout() - скільки чекати відповідь (за замовчуванням 30 с).

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

Повтори:

  • retry(3, 100) - три спроби з паузою 100 мс; масив задає паузи окремо (наростання);
  • третій аргумент when - що повторювати: збої з'єднання й 5xx так, 4xx - ні (помилка в запиті не виправиться повтором);
  • після вичерпаних спроб - RequestException, або остання відповідь з throw: false.

Обережно з не ідемпотентними запитами: повтор POST /payments після таймауту може створити другий платіж - перший міг пройти. Для таких - ключ ідемпотентності, якщо API його підтримує, або без автоматичних повторів.

У черзі повторами зручніше керувати рівнем завдання (tries, backoff, release() на 429), ніж всередині HTTP-клієнта.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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