Питання на співбесіді з 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().
Параметри пишуть після двокрапки, кілька - через кому. У 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 такий метод легко додати самому.
Якщо параметрів стає багато чи вони складні, це сигнал, що перевірку краще перенести в політику чи запит форми.
Групи підключаються автоматично: 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]);
})
View Composer прив'язує дані до шаблону щоразу, коли той рендериться - щоб не дублювати передачу спільних даних у багатьох контролерах.
View::composer('partials.sidebar', function ($view) {
$view->with('categories', Category::all());
});
Тепер будь-яке відображення partials.sidebar автоматично отримає $categories. Зручно для меню, лічильників, віджетів у layout. Реєструється у boot() сервіс-провайдера.
Обидва викликаються однаково - <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 дає два режими:
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().
Автентифікація в 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, відправку виносять у чергу.
«Тонкий контролер» - це той, що лише координує: приймає запит, викликає потрібне й повертає відповідь. Усе інше живе деінде.
Що виносять із контролера і куди:
| Що | Куди |
|---|---|
| Правила валідації | 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, методи не матеріалізують увесь набір - обчислення «ліниві» й виконуються лише при ітерації.
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 147 відкритих вакансій Laravel і PHP. Переглянути вакансії