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

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

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

378 питань

Змусити застосунок падати на ліниве завантаження під час розробки й тестів - тоді N+1 не доходить до рев'ю.

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

Тепер $post->author без попереднього with('author') у локальному оточенні й тестах кидає LazyLoadingViolationException. Тести, що проходять сторінку, ловлять пропущені with() автоматично.

У продакшені не падати, а повідомляти:

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation]);
});

Model::shouldBeStrict() вмикає разом три перевірки: ліниве завантаження, мовчазне відкидання атрибутів поза $fillable і звернення до відсутніх атрибутів.

Інші інструменти:

  • Model::automaticallyEagerLoadRelationships() - Laravel сам довантажить зв'язок для всієї колекції, щойно звернулися до нього в однієї моделі. Зручна страховка, але приховує, які дані сторінка насправді тягне;
  • chaperone() на hasMany - дочірні моделі отримують посилання на батька без зайвого запиту ($comment->post у циклі);
  • лічильник запитів у тестах: перевірка, що сторінка робить не більше N запитів незалежно від кількості записів.

Пастка: ліниве завантаження в Blade-компонентах і вкладених Livewire-компонентах ховається найкраще - саме там ця заборона окупається.

Докладніше в документації: Заборона лінивого завантаження

Вбудованих каналів чотири - mail, database, broadcast, vonage. Усе інше - Slack, Telegram, SMS українського провайдера, пуші - або пакет, або власний канал.

Канал - це клас з одним методом:

class TelegramChannel
{
    public function __construct(private TelegramClient $client)
    {
    }

    public function send(object $notifiable, Notification $notification): void
    {
        $chatId = $notifiable->routeNotificationFor('telegram');

        if ($chatId === null) {
            return;
        }

        $this->client->sendMessage($chatId, $notification->toTelegram($notifiable));
    }
}

Підключення в сповіщенні:

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

    public function toTelegram(object $notifiable): string
    {
        return "Нова вакансія: {$this->vacancy->title}";
    }
}

Куди слати - вирішує сам отримувач:

class User extends Authenticatable
{
    public function routeNotificationForTelegram(): ?string
    {
        return $this->telegram_chat_id;
    }
}

Навіщо це, а не просто виклик API. Сповіщення дає те, чого ручний виклик не має: один клас описує повідомлення для всіх каналів одразу, via() вирішує канали за налаштуваннями користувача, ShouldQueue відправляє асинхронно, а Notification::fake() дозволяє перевірити відправлення в тесті без мережі.

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

Канали бувають трьох типів, і різниця між ними - саме в доступі.

Публічний - слухати може будь-хто, хто знає назву:

class VacancyPublished implements ShouldBroadcast
{
    public function broadcastOn(): Channel
    {
        return new Channel('vacancies');
    }
}

Годиться лише для того, що й так публічне.

Приватний - потребує авторизації:

public function broadcastOn(): PrivateChannel
{
    return new PrivateChannel('user.'.$this->user->id);
}

Правило описується в routes/channels.php:

Broadcast::channel('user.{userId}', function (User $user, int $userId) {
    return $user->id === $userId;
});

Клієнт спершу звертається до /broadcasting/auth, і лише отримавши підпис, підписується на канал.

Presence - приватний плюс список присутніх: хто зараз онлайн, хто друкує.

Де помиляються:

  • Публічний канал для приватних даних. Назву каналу видно у фронтенді, тож Channel('user.'.$id) слухається ким завгодно з перебором id.
  • Замикання авторизації, що завжди повертає true. Це те саме, що публічний канал, але виглядає безпечно.
  • Забувають, що подія несе всю модель. За замовчуванням у payload потрапляють усі публічні властивості - разом із полями, яких клієнт бачити не мусить. Формуйте payload явно через broadcastWith().
  • Приватні дані у назві каналу. Email чи токен у назві видно всім, хто дивиться трафік.

Практично: усе, що стосується конкретного користувача, - PrivateChannel; payload завжди явний.

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

Починається все з простого $user->is_admin, далі зʼявляється редактор, потім модератор - і умови розповзаються по коду.

Крок перший - роль як enum:

enum Role: string
{
    case Admin = 'admin';
    case Editor = 'editor';
    case Author = 'author';
}

Це вже краще за рядки, але перевірки виду $user->role === Role::Editor розкидані по контролерах ламаються, щойно права ролі змінюються.

Крок другий - права, а не ролі. Код питає «чи можна публікувати», а не «чи ти редактор»:

Gate::define('publish', fn (User $user) => $user->hasPermission('publish'));

Роль стає лише набором прав, і зміна набору не потребує правок у коді.

Policy для дій над моделлю:

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->hasPermission('posts.update')
            || $post->author_id === $user->id;
    }
}

before() для суперкористувача - щоб не дублювати перевірку в кожному методі:

public function before(User $user): ?bool
{
    return $user->isAdmin() ? true : null;
}

Повертати треба саме null, а не false: false заборонить дію остаточно й не дасть іншим методам відпрацювати.

Коли брати пакет. spatie/laravel-permission дає ролі, права й кеш перевірок з коробки. Він доречний, коли набір прав змінюють з адмінки; якщо ролей три й вони зашиті в код, enum із Policy простіший.

Що не забути: перевірки прав кешуються не самі - на кожен запит це кілька звернень до бази, тому права користувача варто завантажувати разом із ним.

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

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

Три місця, де це трапляється:

1. Списки. Політика view не фільтрує Invoice::paginate(). Якщо запит не обмежено, сторінка покаже всі рахунки:

$invoices = $request->user()->invoices()->latest()->paginate();

Запит має починатися від користувача (чи від команди / тенанта), а не від моделі.

2. Вкладені маршрути. /users/{user}/posts/{post} без обмеження знайде пост 99, навіть якщо він належить іншому користувачу:

Route::get('/users/{user}/posts/{post}', ...)->scopeBindings();

scopeBindings() шукає пост через $user->posts(), а не по всій таблиці.

3. Пошук, експорт, API, черги. Будь-який код, що збирає дані поза звичайним контролером, - звіт, CSV, ендпойнт пошуку, - теж має обмежувати запит.

Як зробити це системно: глобальний скоп на модель для мультитенантності (з обережністю до withoutGlobalScopes()), репозиторій чи запит, що завжди починається від власника, і тест «користувач A не бачить даних користувача B» для кожного ендпойнта зі списком. UUID замість числових ID від цього не рятують - вони лише ускладнюють перебір.

Докладніше в документації: Обмеження прив'язки моделей

Права ламаються тихо: забута перевірка не дає помилки, вона просто дозволяє зайве. Тому їх тестують на двох рівнях.

1. Правило - напряму, через can():

it('lets only the author edit a draft', function () {
    $author = User::factory()->create();
    $post = Post::factory()->draft()->for($author)->create();

    expect($author->can('update', $post))->toBeTrue()
        ->and(User::factory()->create()->can('update', $post))->toBeFalse();
});

can() проходить увесь шлях: before, саму політику, after. Тест швидкий і точно вказує, яке правило зламалося.

2. Ендпойнт - що перевірку справді викликають:

it('forbids editing someone else\'s post', function () {
    $post = Post::factory()->create();

    $this->actingAs(User::factory()->create())
        ->put("/posts/{$post->id}", ['title' => 'Чуже'])
        ->assertForbidden();

    expect($post->fresh()->title)->not->toBe('Чуже');
});

Ідеальна політика нічого не варта, якщо контролер її не викликав - цей тест ловить саме таке.

Що варто покривати обов'язково:

  • «користувач A не бачить і не змінює даних користувача B» для кожного ресурсу;
  • гість, звичайний користувач, власник, адмін - матриця ролей для ключових дій;
  • списки й експорт, а не лише окремі сторінки;
  • denyAsNotFound() - що повертається саме 404.

Архітектурний тест на кшталт «кожен метод контролера, що змінює дані, викликає Gate::authorize» складно написати надійно, тому покладаються на тести ендпойнтів.

Докладніше в документації: Авторизація через модель користувача

Довідники - ролі, статуси, категорії, налаштування - це дані застосунку, а не демо. Вони потрібні на проді, і сідер для них має витримувати повторний запуск.

Не так:

Role::create(['slug' => 'admin', 'name' => 'Адміністратор']);

Другий запуск або впаде на унікальному індексі, або створить дубль.

Так:

foreach ([
    ['slug' => 'admin', 'name' => 'Адміністратор'],
    ['slug' => 'editor', 'name' => 'Редактор'],
] as $role) {
    Role::updateOrCreate(['slug' => $role['slug']], $role);
}

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

Обережно з updateOrCreate на довідниках, які редагують з адмінки. Він перезапише зміни, зроблені руками. Якщо назву дозволено міняти, оновлювати варто лише технічні поля:

Role::firstOrCreate(['slug' => 'editor'], ['name' => 'Редактор']);

firstOrCreate створює запис, якщо його немає, і не чіпає наявний.

Як запускати на проді:

php artisan db:seed --class=RolesSeeder --force

--force потрібен, бо в продакшн-середовищі Artisan питає підтвердження. Викликати db:seed без --class на проді небезпечно: DatabaseSeeder зазвичай тягне ще й демо-дані.

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

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

Мову зазвичай визначає middleware - з URL, налаштувань користувача чи заголовка браузера:

class SetLocale
{
    public function handle(Request $request, Closure $next): Response
    {
        $locale = $request->route('locale')
            ?? $request->user()?->locale
            ?? $request->getPreferredLanguage(['uk', 'en']);

        App::setLocale($locale);

        return $next($request);
    }
}

Де це стикається з кешем - три різні місця:

1. Кеш застосунку. Значення, покладене під ключем nav.items, буде віддане й іншій мові. Ключ має включати локаль:

Cache::remember('nav.items.'.app()->getLocale(), 3600, fn () => ...);

2. Кеш HTTP і CDN. Якщо мова визначається заголовком Accept-Language, а не URL, то одна адреса віддає різний вміст - і проксі роздасть усім ту версію, яка потрапила в кеш першою. Рятує або Vary: Accept-Language, або мова в URL.

3. Кеш маршрутів. route:cache фіксує маршрути один раз, тож локаль не може бути частиною визначення маршруту - лише параметром.

Чому мову краще тримати в URL. Окремі адреси на кожну мову - це єдиний варіант, який нормально індексується: у пошуку зʼявляються обидві версії, hreflang їх звʼязує, а посилання веде туди, куди вело в того, хто ним поділився. Мова в сесії всього цього не дає.

Дрібниця, яку часто пропускають: App::setLocale() не змінює локаль Carbon - для дат потрібен окремий Carbon::setLocale().

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

Кожен провайдер завантажується на кожному запиті. Якщо він лише реєструє сервіс, потрібний в одному місці з сотні, це марна робота на всіх інших запитах.

Відкладений провайдер реєструється лише тоді, коли його сервіс справді резолвлять:

use Illuminate\Contracts\Support\DeferrableProvider;

class WeatherServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public function register(): void
    {
        $this->app->singleton(WeatherClient::class, function ($app) {
            return new WeatherClient(config('services.weather.key'));
        });
    }

    /**
     * @return array<int, class-string>
     */
    public function provides(): array
    {
        return [WeatherClient::class];
    }
}

Laravel запамʼятовує зіставлення «сервіс → провайдер» у маніфесті й піднімає провайдер лише за потреби.

Умови, за яких це працює:

  • Провайдер має лише register(). Якщо є boot(), слухачі подій, маршрути чи публікація ресурсів - вони не виконаються, поки сервіс ніхто не запросить, тобто фактично ніколи.
  • provides() мусить перелічувати всі привʼязки. Забута привʼязка просто не знайдеться в контейнері.

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

Скільки це коштує. Виграш помітний на застосунку з десятками провайдерів; на невеликому - в межах похибки. Перед тим як відкладати, варто виміряти профайлером, а не робити це «про всяк випадок».

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

Через package discovery: пакет оголошує провайдер і фасади в секції extra.laravel свого composer.json.

"extra": {
    "laravel": {
        "providers": [
            "Acme\\Billing\\BillingServiceProvider"
        ],
        "aliases": {
            "Billing": "Acme\\Billing\\Facades\\Billing"
        }
    }
}

Після composer install чи update Laravel виконує package:discover, збирає ці оголошення з усіх пакетів і кешує список. Провайдер реєструється автоматично - у bootstrap/providers.php нічого дописувати не треба.

Як застосунку відмовитися від автопідключення - наприклад, щоб зареєструвати провайдер умовно лише локально:

"extra": {
    "laravel": {
        "dont-discover": ["barryvdh/laravel-debugbar"]
    }
}

"*" у dont-discover вимикає виявлення для всіх пакетів.

Що варто знати авторові пакета:

  • провайдер пакета має бути легким і бажано відкладеним, якщо лише реєструє прив'язки - він завантажується в кожному застосунку, що встановив пакет;
  • конфігурацію, міграції й шаблони пакет пропонує опублікувати (publishes()), а не копіює сам;
  • mergeConfigFrom() у register() дає значення за замовчуванням, які застосунок може перекрити своїм файлом.

Якщо після встановлення пакета щось «не підхопилося», перевіряють кеш: php artisan package:discover і optimize:clear.

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

Правило unique - це SELECT перед вставкою. Між перевіркою й записом інший запит встигає вставити той самий email.

Запит A: SELECT ... email = 'a@x.com'   -> немає
Запит B: SELECT ... email = 'a@x.com'   -> немає
Запит A: INSERT a@x.com                 -> ок
Запит B: INSERT a@x.com                 -> дубль

Що робити:

  1. Унікальний індекс у базі - єдина справжня гарантія. Валідація лишається для зрозумілого повідомлення в нормальному випадку.
  2. Обробити порушення індексу - Laravel кидає UniqueConstraintViolationException, його перетворюють на ту саму помилку валідації. Або createOrFirst(), який робить це сам.
try {
    $user = User::create($data);
} catch (UniqueConstraintViolationException) {
    throw ValidationException::withMessages(['email' => __('validation.unique', ['attribute' => 'email'])]);
}

Деталі правила, про які питають:

  • при оновленні виключають поточний запис: Rule::unique('users')->ignore($user->id); ignore() ніколи не беруть з даних запиту - лише з моделі, інакше клієнт підставить чужий ID;
  • withoutTrashed() не враховує м'яко видалені записи;
  • регістр: Ada@x.com і ada@x.com різні для unique, тож email нормалізують до нижнього регістру ще до валідації.

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

Для API валідація - це контракт: клієнти парсять помилки програмно, тож формат і поведінка мають бути передбачуваними.

Що Laravel дає з коробки: якщо запит очікує JSON (Accept: application/json), замість перенаправлення повертається 422:

{
    "message": "The email field is required. (and 1 more error)",
    "errors": {
        "email": ["The email field is required."],
        "items.0.quantity": ["The items.0.quantity field must be at least 1."]
    }
}

Вкладені ключі сплющуються в «крапкову» нотацію.

Що варто налаштувати:

  • Невідомі поля. #[FailOnUnknownFields] на запиті форми (або глобально FormRequest::failOnUnknownFields()) відхиляє поля, яких немає в правилах. Помилки клієнта на кшталт emial стають помітними одразу, а не тихо ігноруються.
  • Стабільні повідомлення. Клієнти не мають залежати від тексту - лише від ключів полів і HTTP-коду. Якщо потрібні машинні коди помилок, їх додають власним форматом відповіді.
  • Межі вхідних даних: max для рядків і масивів, ліміт розміру тіла запиту на вебсервері.
  • Мова повідомлень за заголовком Accept-Language, якщо API багатомовний.

Тести: assertUnprocessable() і assertInvalid([...])/assertJsonValidationErrors() фіксують контракт, щоб зміна правил не зламала клієнтів непомітно.

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

php artisan route:cache компілює всі маршрути в один PHP-файл у bootstrap/cache. Laravel більше не виконує routes/*.php на кожен запит, і реєстрація маршрутів стає майже миттєвою - на застосунках із сотнями маршрутів це помітно.

Коли він ламається чи заважає:

  • Маршрути не оновлюються. Після кешування нові маршрути не з'являються, доки кеш не перебудувати. Тому route:cache (чи optimize) виконують на кожному деплої, а локально не вмикають.
  • Маршрути, що залежать від стану під час реєстрації. Якщо в routes/web.php маршрути генеруються з бази чи з конфігурації, яка змінюється без деплою, кеш «заморозить» їх на момент збирання.
  • Конфлікт імен. Два маршрути з однаковим ім'ям без кешу мовчки перезаписують одне одного, а при кешуванні команда падає з помилкою - це навіть корисно.

Маршрути на замиканнях у сучасних версіях кешуються (їх серіалізують), але звичайна практика - контролери: їх легше тестувати й шукати.

Діагностика: php artisan route:list показує маршрути з кешу, якщо він є; route:clear прибирає кеш.

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

Неявна прив'язка за замовчуванням шукає модель за первинним ключем. Є кілька рівнів налаштування - від простого до повного контролю.

Інша колонка для одного маршруту:

Route::get('/posts/{post:slug}', [PostController::class, 'show']);

Інша колонка для моделі завжди:

public function getRouteKeyName(): string
{
    return 'slug';
}

Власна логіка - наприклад, лише опубліковані пости для гостей:

public function resolveRouteBinding($value, $field = null): ?Model
{
    return $this->where($field ?? $this->getRouteKeyName(), $value)
        ->when(! auth()->user()?->isEditor(), fn ($q) => $q->published())
        ->first();
}

null дає 404.

Явна прив'язка в провайдері - коли параметр не відповідає моделі напряму:

Route::bind('user', fn (string $value) => User::where('name', $value)->firstOrFail());

Поряд корисне:

  • ->withTrashed() на маршруті - знаходити м'яко видалені моделі;
  • ->missing(fn () => redirect(...)) - що робити замість 404;
  • scopeBindings() - вкладена модель шукається через батьківську, і чужий ID не пройде.

Важливо: прив'язка моделі - це пошук, а не авторизація. Перевірку прав робить політика.

Докладніше в документації: Налаштування логіки розв'язання

Blade компілює шаблони в звичайний PHP і кешує в storage/framework/views, тож сам синтаксис майже нічого не коштує. Повільними сторінки роблять інші речі.

Що зазвичай гальмує:

  • Запити з шаблонів. $post->author->name у циклі без with() - N+1, найчастіша причина. Model::preventLazyLoading() ловить це в розробці.
  • Сотні компонентів. Кожен компонент з класом - створення об'єкта через контейнер, злиття атрибутів, окремий шаблон. Таблиця на 500 рядків з п'ятьма компонентами в рядку - тисячі рендерів.
  • Вкладені Livewire-компоненти в циклі - кожен зі своїм станом і запитами.
  • @include у великих циклах і важкі обчислення в шаблоні.
  • Перший запит після деплою, якщо шаблони не прекомпільовані.

Як виміряти: Debugbar чи Telescope показують кількість і час шаблонів і запитів; профайлер (SPX, Blackfire, Xdebug) - де саме витрачено час. Корисно порівняти час до рендеру й сам рендер.

Що робити:

  • php artisan view:cache (входить в optimize) на деплої;
  • жадібно завантажувати дані в контролері й передавати в шаблон готове;
  • у великих списках - простіший HTML замість глибоко вкладених компонентів; анонімні компоненти дешевші за компоненти з класом;
  • кешувати фрагменти, що рідко змінюються (Cache::remember навколо view()->render()), або всю гостьову сторінку на рівні CDN;
  • пагінація замість виводу всього.

Після змін - знову виміряти: оптимізація без вимірювань часто прискорює не те.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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