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

Питання на співбесіді з 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 перевіряється ще до виклику методу контролера - коли контейнер створює його як аргумент. Порядок кроків:

  1. authorize() - false чи заборонена відповідь політики → AuthorizationException → 403. Якщо методу немає, запит вважається дозволеним;
  2. prepareForValidation() - підготувати дані (нормалізувати, об'єднати);
  3. rules() + валідатор, разом з after() - додаткові перевірки після основних правил;
  4. при помилці - failedValidation(): редирект назад з помилками чи 422 для JSON;
  5. при успіху - 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.

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

Атрибути переносять налаштування контейнера з провайдера ближче до класу, якого вони стосуються.

#[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.

Коли що обирати: атрибут зручний, коли правило належить самому класу й читається разом з ним. Провайдер лишається місцем для складної логіки створення, залежної від конфігурації, і для прив'язок до чужих класів, які не можна позначити атрибутом.

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

Через контекстні атрибути чи контекстну прив'язку - тоді залежність видно в конструкторі, а не всередині методів.

Атрибутами (найкоротше):

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() на мільйонній таблиці впаде з браку пам'яті.

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

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-*.

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

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

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.

Два способи виправити:

  1. Статичні маршрути - вище за параметризовані.
  2. Обмежити параметр, щоб він не ловив чуже:
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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 101 Middle 147 Senior 130

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