Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Класична скарга: користувач вибрав фільтри, перейшов на другу сторінку - і фільтри злетіли. Причина в тому, що посилання пагінації за замовчуванням несуть лише page.
Додати поточні параметри:
$posts = Post::filter($request)->paginate(15)->withQueryString();
withQueryString() переносить усі параметри рядка запиту в посилання пагінації. Якщо потрібні конкретні - appends():
$posts->appends(['sort' => $request->input('sort')]);
Друга частина проблеми - сортування без унікального ключа. Якщо сортувати за колонкою з повторами, рядки з однаковим значенням база може віддати в різному порядку на різних сторінках - і один запис зʼявиться двічі, а інший зникне:
// хитко: багато записів з однаковою датою
Post::orderByDesc('published_at')->paginate(15);
// стабільно: дозволяємо ключем
Post::orderByDesc('published_at')->orderByDesc('id')->paginate(15);
Третя - сторінка поза межами. Після зміни фільтра запис може стати менше, ніж потрібно для сторінки 7, і користувач бачить порожньо. Скидання номера сторінки при зміні фільтра вирішує це; у Livewire для того є resetPage().
Для SEO варто памʼятати ще про одне: кожна комбінація фільтрів із номером сторінки - окрема URL. Якщо їх багато, сторінки з фільтрами зазвичай закривають від індексації, лишаючи в ній базовий список.
Form Request перевіряється ще до виклику методу контролера - коли контейнер створює його як аргумент. Порядок кроків:
authorize()-falseчи заборонена відповідь політики →AuthorizationException→ 403. Якщо методу немає, запит вважається дозволеним;prepareForValidation()- підготувати дані (нормалізувати, об'єднати);rules()+ валідатор, разом зafter()- додаткові перевірки після основних правил;- при помилці -
failedValidation(): редирект назад з помилками чи 422 для JSON; - при успіху -
passedValidation(), і лише потім виконується контролер.
after() - перевірки, яким потрібні вже провалідовані значення чи кілька полів одразу:
public function after(): array
{
return [
function (Validator $validator) {
if ($this->date('ends_at') <= $this->date('starts_at')) {
$validator->errors()->add('ends_at', 'Кінець має бути пізніше за початок.');
}
},
new EnsureSlotIsFree, // клас з __invoke(Validator $validator)
];
}
Налаштування поведінки - властивостями чи, у Laravel 13, атрибутами класу:
#[StopOnFirstFailure] // зупинитися на першому полі з помилкою
#[RedirectToRoute('checkout.show')] // куди повертати замість back()
#[ErrorBag('checkout')] // окремий мішок помилок для кількох форм на сторінці
#[FailOnUnknownFields] // помилка на полях, яких немає в rules()
final class CheckoutRequest extends FormRequest { /* ... */ }
FailOnUnknownFields - новинка Laravel 13: якщо клієнт надіслав поле, якого немає в rules(), кожне таке поле отримує помилку prohibited. Захищає від масового присвоєння й опечаток у назвах полів на фронтенді. Глобально - FormRequest::failOnUnknownFields() у сервіс-провайдері.
Власна реакція на помилку - перевизначити failedValidation():
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json([
'title' => 'Validation failed',
'errors' => $validator->errors(),
], 422));
}
Для API це зазвичай краще зробити один раз у bootstrap/app.php для всіх запитів, а не в кожному класі.
Що пам'ятати:
authorize()не замінює політик: вона виконується до валідації, тож дані запиту ще не перевірені - звертатися до них обережно;validated()іsafe()повертають лише перевірені поля - саме їх передають у модель, а неall();after()виконується навіть після помилок основних правил - перевірки в ньому мають враховувати, що поля можуть бути порожніми чи некоректними.
Докладніше в документації: Валідація: додаткова перевірка у Form Request
Транзакція гарантує атомарність: або всі операції виконуються, або жодна.
DB::transaction(function () use ($order) {
$order->save();
$order->items()->createMany($items);
Inventory::decrement($order->product_id, $order->qty);
});
При винятку всередині замикання Laravel автоматично робить rollBack(). Ручний контроль:
DB::beginTransaction();
try {
// ...
DB::commit();
} catch (Throwable $e) {
DB::rollBack();
throw $e;
}
Другий аргумент transaction($cb, 3) задає кількість повторів при deadlock.
migrate:rollback відкочує останній «батч» міграцій, викликаючи їхні методи down():
php artisan migrate:rollback # останній батч
php artisan migrate:rollback --step=1 # рівно одну міграцію
migrate:reset відкочує всі міграції по черзі. migrate:refresh - відкочує все й накатує заново. migrate:fresh - видаляє всі таблиці й накатує міграції з нуля, взагалі не заглядаючи в down().
Що з цього небезпечне в проді: усі чотири. Кожна знищує дані, а migrate:fresh робить це найшвидше й без шансу на down(). У проді припустимий лише php artisan migrate.
Laravel сам питає підтвердження в продакшн-середовищі, і саме тому --force не варто вписувати в скрипти «щоб не заважало».
Практика, що рятує:
- Перевіряйте відкат локально одразу після написання:
migrate→migrate:rollback --step=1→migrate. Половина міграцій має неробочийdown(), і виявляється це в найгірший момент. - Пишіть
down()чесно, а якщо відкат неможливий - хай кидає виняток, це краще за мовчазну порожню реалізацію. - Не редагуйте вже застосовану міграцію - додавайте нову. Виправлений файл не перезастосується там, де він уже відпрацював.
- Видалення колонки й перейменування - окремими релізами від коду, що їх читає, інакше деплой ламає працюючий застосунок у проміжку.
Dependency Injection - патерн, за якого клас отримує залежності ззовні (зазвичай через конструктор), а не створює їх сам. Це знижує зв'язування й полегшує тестування (можна підсунути мок).
class OrderController
{
public function __construct(
private PaymentGateway $gateway, // інжектується контейнером
) {}
}
У Laravel DI працює «з коробки»: контейнер читає type-hints і автоматично будує граф залежностей. Інжектити можна й у методи контролера (method injection), зокрема сам Request.
Атрибути переносять налаштування контейнера з провайдера ближче до класу, якого вони стосуються.
#[Bind] на інтерфейсі - яку реалізацію впроваджувати, з урахуванням оточення:
#[Bind(RedisEventPusher::class)]
#[Bind(FakeEventPusher::class, environments: ['local', 'testing'])]
interface EventPusher {}
Замінює $this->app->bind(EventPusher::class, ...) у провайдері.
#[Singleton] і #[Scoped] на класі - скільки екземплярів:
#[Singleton]
class ExchangeRates {}
Singleton - один на весь процес, Scoped - один на запит чи завдання.
Контекстні атрибути на параметрах - що саме впровадити:
public function __construct(
#[Config('app.timezone')] private string $timezone,
#[Storage('s3')] private Filesystem $disk,
#[CurrentUser] private User $user,
) {}
Є також Cache, DB, Log, Auth, Context, Give, RouteParameter, Tag.
Коли що обирати: атрибут зручний, коли правило належить самому класу й читається разом з ним. Провайдер лишається місцем для складної логіки створення, залежної від конфігурації, і для прив'язок до чужих класів, які не можна позначити атрибутом.
Через контекстні атрибути чи контекстну прив'язку - тоді залежність видно в конструкторі, а не всередині методів.
Атрибутами (найкоротше):
class ReportExporter
{
public function __construct(
#[Storage('reports')] private Filesystem $disk,
#[Config('reports.per_page')] private int $perPage,
#[Log('reports')] private LoggerInterface $log,
) {}
}
Контекстною прив'язкою в провайдері - коли атрибут на чужому класі не поставиш:
$this->app->when(ReportExporter::class)
->needs('$perPage')
->giveConfig('reports.per_page');
$this->app->when(ReportExporter::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('reports'));
Чим це краще за Storage::disk('reports') усередині методу:
- залежності чесно перелічені в конструкторі - видно, від чого клас залежить;
- у тесті їх підставляють напряму, без підміни фасадів;
- різним класам можна дати різні реалізації того самого інтерфейсу.
Власні атрибути створюють, реалізувавши контракт ContextualAttribute з методом resolve().
Індекс - структура (зазвичай B-дерево), що пришвидшує пошук і сортування за стовпцем ціною уповільнення запису та додаткового місця.
$table->index('status'); // звичайний
$table->unique('email'); // унікальний
$table->index(['user_id', 'created_at']); // композитний
Правила:
- Індексуйте стовпці у
WHERE,JOIN,ORDER BY, зовнішні ключі. - Композитний індекс корисний за префіксом стовпців (порядок важливий).
EXPLAINпоказує, чи використовується індекс.- Зайві індекси шкодять записам - балансуйте.
На таблиці в кілька мільйонів рядків необережна міграція блокує запис на хвилини, і застосунок лежить.
Що блокує довго:
- Додавання колонки
NOT NULLзі значенням за замовчуванням у старих версіях MySQL переписує таблицю цілком. У MySQL 8 і PostgreSQL 11+ це вже метадані, але перевіряти варто саме свою версію. - Створення індексу звичайним
CREATE INDEXтримає таблицю на час побудови. - Зміна типу колонки майже завжди означає перезапис.
Безпечний порядок для нової колонки:
// 1. Спершу nullable - миттєво, без перезапису.
Schema::table('vacancies', function (Blueprint $table) {
$table->string('short_name')->nullable()->after('title');
});
Далі заповнити дані пачками - окремою командою, не в міграції:
Vacancy::whereNull('short_name')->chunkById(500, function ($vacancies) {
foreach ($vacancies as $vacancy) {
$vacancy->update(['short_name' => Str::limit($vacancy->title, 40, '')]);
}
});
І лише потім, якщо потрібно, робити колонку обовʼязковою - окремою міграцією, коли даних без значення вже немає.
Індекс без блокування:
// PostgreSQL
DB::statement('CREATE INDEX CONCURRENTLY vacancies_level_index ON vacancies (level)');
CONCURRENTLY не можна виконувати всередині транзакції, тож у міграції потрібно вимкнути обгортання:
public $withinTransaction = false;
Загальне правило: розділяйте зміну схеми й заповнення даних. Міграція має бути швидкою операцією над структурою, а перенесення даних - командою, яку можна запустити, зупинити й продовжити.
Кожен запуск php artisan migrate записує виконані міграції в таблицю migrations з одним номером пакета (batch).
php artisan migrate:rollback # відкотити останній пакет цілком
php artisan migrate:rollback --step=1 # рівно одну останню міграцію
php artisan migrate:rollback --batch=3 # конкретний пакет
php artisan migrate:status # що виконано і в якому пакеті
Відкат викликає down(), тож вона має справді повертати попередній стан: видалити створену колонку, відновити змінений тип.
Інші команди - і чим вони небезпечні:
migrate:reset- відкотити все;migrate:refresh- відкотити все й виконати знову (проходить усіdown());migrate:fresh- видалити всі таблиці й виконати міграції з нуля,down()не викликається.
Останні три знищують дані - на продакшені їх не запускають, а локально з копією прод-бази - теж обережно.
На продакшені відкат міграції, яка видалила колонку з даними, не поверне дані. Тому руйнівні зміни роблять окремим релізом пізніше, а відкат релізу - новою міграцією вперед, а не rollback.
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});
foreignId('post_id')->constrained()- колонкаunsignedBigIntegerплюс обмеження наposts.id(таблицю Laravel виводить з назви колонки);foreignIdFor(User::class)- назву колонки бере з моделі.
Що буде з дітьми при видаленні батька - вирішують явно:
cascadeOnDelete()- видалити разом (коментарі поста);nullOnDelete()- обнулити посилання (автор видалив акаунт, коментар лишається анонімним; колонка має бутиnullable);restrictOnDelete()- заборонити видалення, поки є діти (клієнт із рахунками).
Що варто знати:
- обмеження працює на рівні бази й обходить Eloquent - події моделей для каскадно видалених рядків не спрацюють;
- м'яке видалення батька каскад не запускає - це звичайний
UPDATE; - MySQL сам створює індекс для зовнішнього ключа, PostgreSQL - ні, тож там індекс на
post_idдодають окремо; - видалити ключ:
$table->dropForeign(['post_id']).
Chunking обробляє великі набори даних порціями, щоб не тримати всі рядки в пам'яті одразу.
Post::chunk(200, function ($posts) {
foreach ($posts as $post) { /* ... */ }
});
chunkById(200, ...)- безпечніший, коли під час обробки змінюються записи (нумерує заid, а не за offset).lazy()/cursor()- повертають LazyCollection: ще менше пам'яті, але один активний запит.
Без chunking Post::all() на мільйонній таблиці впаде з браку пам'яті.
Rate Limiting обмежує кількість запитів за період. Іменовані обмежувачі визначають через RateLimiter::for() (у Laravel 11+ зазвичай у bootstrap/app.php або AppServiceProvider::boot()):
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
Застосування до маршрутів:
Route::middleware('throttle:api')->group(...);
Route::post('/login', ...)->middleware('throttle:5,1'); // 5 за хвилину
При перевищенні - HTTP 429 із заголовками Retry-After та X-RateLimit-*.
Група задає спільні налаштування для кількох маршрутів, щоб не повторювати їх у кожному.
Route::middleware(['auth', 'verified'])
->prefix('dashboard')
->name('dashboard.')
->controller(InvoiceController::class)
->group(function () {
Route::get('/invoices', 'index')->name('invoices.index'); // /dashboard/invoices
Route::get('/invoices/{invoice}', 'show')->name('invoices.show');
});
Що можна спільно задати:
middleware()- автентифікація, верифікація, обмеження частоти;prefix()- префікс URI;name()- префікс імен маршрутів (крапку в кінці пишуть самі);controller()- спільний контролер, тоді в маршруті лише назва методу;domain()- піддомен, зокрема з параметром:{account}.example.com;scopeBindings()- вкладені моделі шукаються через батьківську.
Вкладені групи об'єднують налаштування: middleware додаються, префікси й імена склеюються.
На практиці групи - головний спосіб не забути auth для нового маршруту: він просто потрапляє в захищену групу.
Маршрути перевіряються в порядку реєстрації, і перемагає перший збіг.
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::get('/posts/create', [PostController::class, 'create']); // ніколи не спрацює
Запит /posts/create збігається з першим маршрутом: create стає значенням {post}, прив'язка моделі не знаходить пост - 404.
Два способи виправити:
- Статичні маршрути - вище за параметризовані.
- Обмежити параметр, щоб він не ловив чуже:
Route::get('/posts/{post}', [PostController::class, 'show'])->whereNumber('post');
Route::get('/users/{name}', ...)->whereAlpha('name');
Route::get('/orders/{order}', ...)->whereUuid('order');
Route::get('/category/{type}', ...)->whereIn('type', ['news', 'guides']);
Глобальне обмеження для всіх маршрутів з параметром {id}:
// AppServiceProvider::boot()
Route::pattern('id', '[0-9]+');
Route::resource() реєструє create раніше за {post} саме з цієї причини. Проблема виникає, коли маршрути додають вручну в різних місцях файлу.
Докладніше в документації: Обмеження параметрів регулярними виразами
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 147 відкритих вакансій Laravel і PHP. Переглянути вакансії