Питання на співбесіді з 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 заради швидкості та меншого споживання пам'яті.
У 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).
Через 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().
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.
Логування в 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 додає термін дії.
APP_KEY - ключ, яким Laravel шифрує й підписує дані.
Що від нього залежить:
- шифровані cookie, зокрема сесійна;
Crypt::encrypt()і кастencryptedу моделях;- підписані URL (
URL::signedRoute()); - зашифровані завдання черги (
ShouldBeEncrypted).
Паролі від нього не залежать - вони хешуються bcrypt чи argon2, а не шифруються.
Якщо ключ змінити: усі сесії завершаться (cookie не розшифруються), підписані посилання перестануть працювати, а зашифровані колонки в базі стане неможливо прочитати.
Як змінити без цього: старий ключ переносять у APP_PREVIOUS_KEYS. Laravel шифрує новим, а розшифровує по черзі новим і попередніми.
Якщо ключ витік - зловмисник може підробляти cookie й підписані URL, розшифрувати дані з бази. Тому:
- згенерувати новий ключ, старий - тимчасово в
APP_PREVIOUS_KEYS; - перешифрувати дані з кастом
encryptedновим ключем; - прибрати старий ключ з
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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії