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

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

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

378 питань

Звичайне middleware працює навколо запиту: щось робить до $next($request), щось - після, але завжди до того, як відповідь піде користувачу.

Terminable middleware виконується після відправлення відповіді:

class LogRequestDuration
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }

    public function terminate(Request $request, Response $response): void
    {
        // Користувач уже отримав сторінку - це його не затримує.
        RequestLog::create([
            'path' => $request->path(),
            'status' => $response->getStatusCode(),
            'duration' => microtime(true) - LARAVEL_START,
        ]);
    }
}

Метод terminate() викликається з $app->terminate() після send().

Важлива умова: це працює лише коли сервер уміє віддати відповідь і продовжити виконання - тобто на FastCGI з fastcgi_finish_request(). За іншої конфігурації користувач усе одно чекатиме.

Ще одна деталь: за замовчуванням у terminate() потрапляє новий екземпляр middleware. Якщо потрібен той самий - зареєструйте його синглтоном:

$this->app->singleton(LogRequestDuration::class);

Для чого доречно: запис аналітики, логування тривалості, дрібне прибирання - те, що не впливає на відповідь.

Для чого ні: будь-що довге. Воно все одно тримає PHP-воркер зайнятим, тож ця робота належить у чергу, а не в terminate().

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

Параметри пишуть після двокрапки, кілька - через кому. У handle() вони приходять після $next.

Route::put('/posts/{post}', [PostController::class, 'update'])
    ->middleware('role:editor,admin');
class EnsureUserHasRole
{
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        if (! $request->user()?->hasAnyRole($roles)) {
            abort(403);
        }

        return $next($request);
    }
}

Знайомі приклади з фреймворку:

  • throttle:60,1 - 60 запитів на хвилину;
  • can:update,post - перевірка політики з моделлю з параметра маршруту;
  • auth:sanctum - гард для автентифікації;
  • cache.headers:public;max_age=3600 - параметри через крапку з комою.

Без рядків: вбудовані middleware мають статичні методи using(), що збирають цей рядок, - так IDE бачить посилання на клас:

->middleware(ThrottleRequests::using('uploads'))
->middleware(Authorize::using('update', 'post'))

Для власного middleware такий метод легко додати самому.

Якщо параметрів стає багато чи вони складні, це сигнал, що перевірку краще перенести в політику чи запит форми.

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

Групи підключаються автоматично: web - до routes/web.php, api - до routes/api.php.

Група web - усе для сторінок у браузері:

  • EncryptCookies і AddQueuedCookiesToResponse - шифровані cookie;
  • StartSession - сесія;
  • ShareErrorsFromSession - змінна $errors у шаблонах;
  • PreventRequestForgery - захист від CSRF (у Laravel 13 спершу перевіряє заголовок Sec-Fetch-Site, потім токен);
  • SubstituteBindings - прив'язка моделей до параметрів маршруту.

Група api - лише SubstituteBindings. Немає сесії, cookie й CSRF: API зазвичай автентифікується токеном (auth:sanctum) і не тримає стану.

Наслідки, про які питають:

  • маршрут з routes/api.php не бачить сесії й old();
  • SPA на тому самому домені через Sanctum працює з cookie - для цього Sanctum додає потрібні middleware до api через statefulApi();
  • routes/api.php у свіжому Laravel 11+ немає - його створює php artisan install:api.

Додати своє в групу:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [SetLocale::class]);
    $middleware->api(prepend: [ForceJsonResponse::class]);
})

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

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

View::composer('partials.sidebar', function ($view) {
    $view->with('categories', Category::all());
});

Тепер будь-яке відображення partials.sidebar автоматично отримає $categories. Зручно для меню, лічильників, віджетів у layout. Реєструється у boot() сервіс-провайдера.

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

Обидва викликаються однаково - <x-alert type="error" />. Різниця в тому, де живе логіка.

Анонімний компонент - лише шаблон у resources/views/components:

{{-- components/alert.blade.php --}}
@props(['type' => 'info'])

<div {{ $attributes->class(['alert', 'alert-'.$type]) }}>
    {{ $slot }}
</div>

@props оголошує дані; решта атрибутів потрапляє в $attributes.

Компонент з класом - php artisan make:component Alert створює клас і шаблон:

class Alert extends Component
{
    public function __construct(public string $type = 'info', private Formatter $formatter) {}

    public function isUrgent(): bool
    {
        return $this->type === 'error';
    }

    public function render(): View
    {
        return view('components.alert');
    }
}

Публічні властивості й методи доступні в шаблоні, а конструктор отримує залежності з контейнера.

Коли що:

  • анонімний - для презентаційних компонентів: кнопки, картки, поля форм. Їх більшість;
  • з класом - коли потрібні залежності, обчислення чи метод shouldRender().

Спільне для обох: слоти ($slot, іменовані <x-slot:title>), злиття атрибутів (merge, class), @aware для даних батьківського компонента.

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

Через стеки: макет оголошує місце, сторінки й компоненти додають туди вміст.

{{-- layouts/app.blade.php --}}
<head>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('scripts')
</head>

{{-- сторінка зі графіком --}}
@push('scripts')
    <script src="https://cdn.example.com/chart.js" defer></script>
@endpush

Корисні варіанти:

  • @prepend - додати на початок стека;
  • @pushOnce('scripts') - додати лише раз, навіть якщо компонент з цим скриптом стоїть на сторінці десять разів;
  • @once ... @endonce - те саме для довільного блоку;
  • @pushIf($condition, 'scripts') - за умовою.

Чим це краще за @section: секцію перезаписує дочірній шаблон, а в стек можуть додавати кілька незалежних місць - сторінка, кожен компонент, вкладений шаблон.

Порядок: стек виводиться там, де стоїть @stack, а вміст збирається з усіх шаблонів, що рендеряться для сторінки. Компонент, відрендерений пізніше за @stack (наприклад, у Livewire-оновленні), уже не потрапить у head - для динамічних частин скрипти підключають інакше.

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

Це різні рівні автентифікації:

  • Breeze - стартовий набір UI: реєстрація/вхід/скидання пароля на Blade+Livewire або React/Vue. Для швидкого старту.
  • Fortify - headless-бекенд автентифікації (без UI): логіка реєстрації, 2FA, скидання пароля. Під ним працює Breeze/Jetstream.
  • Sanctum - легка автентифікація для SPA (через cookie) та простих API-токенів. Дефолт для більшості API.
  • Passport - повноцінний OAuth2-сервер: видача access/refresh токенів стороннім клієнтам. Обирають, коли потрібен саме OAuth2.

Правило: SPA чи мобільний застосунок → Sanctum; «увійти через наш сервіс» для третіх сторін → Passport.

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

Sanctum дає два режими:

1. API-токени - модель випускає токен, який клієнт шле в заголовку Authorization: Bearer ...:

$token = $user->createToken('mobile', ['posts:read'])->plainTextToken;

Route::middleware('auth:sanctum')->get('/user', fn (Request $r) => $r->user());

2. SPA-автентифікація - для односторінкових застосунків на тому ж домені використовує звичайні сесійні cookie + CSRF (без зберігання токенів у JS, що безпечніше).

Токени підтримують abilities (scopes): $user->tokenCan('posts:read'). Трейт HasApiTokens додає tokens()-зв'язок і createToken().

Докладніше в документації: Sanctum: API-токени

Автентифікація в Laravel тримається на двох поняттях з config/auth.php.

  • Гард визначає, як користувача впізнають у запиті: session - за сесією й cookie, sanctum - ще й за токеном у заголовку.
  • Провайдер визначає, звідки користувача беруть: зазвичай Eloquent-модель User чи таблиця бази.
'guards' => [
    'web'   => ['driver' => 'session', 'provider' => 'users'],
    'admin' => ['driver' => 'session', 'provider' => 'admins'],
],
'providers' => [
    'users'  => ['driver' => 'eloquent', 'model' => App\Models\User::class],
    'admins' => ['driver' => 'eloquent', 'model' => App\Models\Admin::class],
],

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

Route::middleware('auth:admin')->group(fn () => /* ... */);

Auth::guard('admin')->attempt($credentials);

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

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

Обмежити кількість невдалих спроб - за ключем, що поєднує email та IP.

public function authenticate(): void
{
    $key = Str::transliterate(Str::lower($this->input('email'))) . '|' . $this->ip();

    if (RateLimiter::tooManyAttempts($key, 5)) {
        throw ValidationException::withMessages([
            'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
        ]);
    }

    if (! Auth::attempt($this->only('email', 'password'))) {
        RateLimiter::hit($key);
        throw ValidationException::withMessages(['email' => __('auth.failed')]);
    }

    RateLimiter::clear($key);
}

Чому саме такий ключ:

  • Лише IP - заблокує цілий офіс за NAT, а зловмисник із сотнею адрес обійде.
  • Лише email - будь-хто зможе заблокувати вхід чужому акаунту, просто вводячи неправильний пароль.
  • Email + IP гальмує перебір конкретного акаунта з конкретної адреси.

Що ще допомагає:

  • однакове повідомлення «Невірний email або пароль» - щоб не підказувати, які email зареєстровані;
  • не блокувати акаунт назавжди після N спроб - це інструмент для атаки на доступність;
  • двофакторна автентифікація, після якої перебір пароля сам по собі нічого не дає.

Стартові набори Laravel 13 вже обмежують спроби входу. Якщо застосунок за проксі (Cloudflare), перевірте довірені проксі - інакше всі запити матимуть IP проксі.

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

Notification - одне повідомлення, яке можна доставити кількома каналами одночасно.

class InvoicePaid extends Notification
{
    public function via(object $notifiable): array
    {
        return ['mail', 'database', 'broadcast'];
    }

    public function toMail($notifiable): MailMessage { /* ... */ }
    public function toArray($notifiable): array { /* для database */ }
}

$user->notify(new InvoicePaid($invoice));

Канали з коробки: mail, database (зберігає в notifications), broadcast (WebSockets), Vonage (SMS), Slack. Є community-канали (Telegram, push). Канал database зручний для «дзвіночка» сповіщень у UI; реалізувавши ShouldQueue, відправку виносять у чергу.

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

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

Що виносять із контролера і куди:

Що Куди
Правила валідації Form Request
Перевірка прав Policy або authorize() у Form Request
Бізнес-логіка Service чи Action-клас
Складна вибірка Локальний скоп на моделі
Формат відповіді API Resource
Побічні ефекти (лист, індексація) Слухач події або джоба в черзі

Було:

public function store(Request $request)
{
    $data = $request->validate([...]);          // валідація

    if (! $request->user()->can('create', Post::class)) {   // права
        abort(403);
    }

    $post = Post::create($data);                 // логіка
    $post->tags()->sync($data['tags']);
    Mail::to($post->author)->send(new PostCreated($post));  // побічний ефект

    return response()->json([                     // формат
        'id' => $post->id,
        'title' => $post->title,
    ]);
}

Стало:

public function store(StorePostRequest $request, CreatePost $createPost)
{
    return new PostResource($createPost->handle($request->validated()));
}

Валідація й права спрацювали до входу в метод, логіка з побічними ефектами - усередині CreatePost, формат - у ресурсі.

Навіщо це насправді. Не заради краси: логіку з контролера не викликати ні з Artisan-команди, ні з черги, ні з тесту без HTTP-запиту. Щойно та сама операція знадобилась у другому місці, тонкий контролер перестає бути питанням смаку.

Де межа. Post::create($request->validated()) не потребує ні сервісу, ні ресурсу - шар заради шару лише додає файлів. Виносьте, коли з'явилася друга причина: кілька моделей в одній операції, виклик не з HTTP або контролер перестав вміщатися на екран.

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

У запит форми (Form Request) і політику - тоді контролер отримує вже перевірені дані.

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Order::class);
    }

    public function rules(): array
    {
        return [
            'items'              => ['required', 'array', 'max:50'],
            'items.*.product_id' => ['required', 'exists:products,id'],
        ];
    }
}

class OrderController
{
    public function store(StoreOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
    {
        $order = $placeOrder($request->user(), $request->validated());

        return redirect()->route('orders.show', $order);
    }
}

Розподіл відповідальності:

  • Form Request - формат вхідних даних і, за потреби, доступ до дії;
  • Політика - правила доступу до моделі (update, delete), щоб їх перевикористовували контролери, Livewire, API й Blade (@can);
  • Дія чи сервіс - бізнес-логіка, яка не знає про HTTP;
  • Контролер - лише склеює: приймає запит, викликає дію, повертає відповідь.

Перевага не лише в чистоті: запит форми й політику тестують окремо, а ту саму дію викликають з команди чи черги без підробки HTTP-запиту.

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

Ресурсний контролер описує CRUD над однією сутністю: index, show, store, update, destroy. Але не кожна дія вкладається в ці сім методів.

class PublishPostController
{
    public function __invoke(Post $post, PublishPost $publish): RedirectResponse
    {
        Gate::authorize('publish', $post);
        $publish($post);

        return back()->with('status', 'Опубліковано');
    }
}

Route::post('/posts/{post}/publish', PublishPostController::class);

Коли __invoke доречний:

  • дія не є CRUD: «опублікувати», «скасувати замовлення», «експортувати звіт»;
  • у дії свої залежності, які не потрібні іншим методам;
  • ресурсний контролер розрісся до десятка нестандартних методів.

Коли ресурсний: стандартні операції над сутністю - так маршрути й імена передбачувані (posts.update), а Route::resource() реєструє їх одним рядком.

Типова еволюція: PostController з методами publish, unpublish, duplicate, export розбивають на ресурсний CRUD і кілька контролерів-дій. Кожен файл короткий, назва каже, що він робить, а залежності не змішуються.

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

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

LazyCollection використовує PHP-генератори, щоб тримати в пам'яті лише один елемент за раз - критично для величезних наборів.

LazyCollection::make(function () {
    $handle = fopen('huge.csv', 'r');
    while (($line = fgets($handle)) !== false) {
        yield $line;
    }
})->filter(...)->take(100)->each(...);

З Eloquent:

User::cursor()->each(function ($user) { /* по одному рядку */ });

На відміну від звичайної Collection, методи не матеріалізують увесь набір - обчислення «ліниві» й виконуються лише при ітерації.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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