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

Laravel: питання на співбесіді рівня Middle

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

147 питань

Repository - патерн, що ховає доступ до даних за інтерфейсом: код працює з PostRepositoryInterface, не знаючи, звідки беруться записи.

interface PostRepositoryInterface
{
    public function published(): Collection;
}

class EloquentPostRepository implements PostRepositoryInterface
{
    public function published(): Collection
    {
        return Post::where('is_published', true)->get();
    }
}

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

Аргумент «щоб тестувати» теж слабкий у Laravel: є RefreshDatabase і фабрики, тож тест із реальною базою пишеться просто й перевіряє більше, ніж мок репозиторію.

Що при цьому втрачається: зникають скопи, ліниві зв'язки й with() у місці виклику - або репозиторій обростає методами на кожну комбінацію фільтрів, або починає повертати Builder, і абстракція протікає.

Коли Repository справді доречний:

  • Дані живуть не в Eloquent - зовнішнє API, Elasticsearch, кілька джерел за одним інтерфейсом.
  • Домен свідомо відокремлений від фреймворку (DDD), і моделі домену не є Eloquent-моделями.

Що зазвичай працює краще: локальні скопи для повторюваних вибірок і Action/Service-класи для операцій. Вони дають ту саму зібраність без прошарку, який дублює Eloquent.

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

Service Container (IoC-контейнер) керує залежностями класів і виконує dependency injection. Він уміє автоматично будувати об'єкти, рекурсивно резолвлячи їхні залежності через type-hints конструктора.

// біндинг інтерфейсу до реалізації (зазвичай у Service Provider)
$this->app->bind(PaymentGateway::class, StripeGateway::class);

// тепер усюди, де просять PaymentGateway, прийде StripeGateway
public function __construct(private PaymentGateway $gateway) {}
  • bind() - нова реалізація щоразу.
  • singleton() - один екземпляр на весь життєвий цикл запиту.
  • app(PaymentGateway::class) - ручне резолвлення.

Це серце Laravel: контролери, middleware, події - усе резолвиться через контейнер.

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

N+1 виникає, коли ви завантажуєте N моделей одним запитом, а потім у циклі звертаєтесь до їхнього зв'язку - це генерує ще N запитів.

$posts = Post::all(); // 1 запит
foreach ($posts as $post) {
    echo $post->author->name; // +1 запит на кожен пост → N запитів
}

Рішення - eager loading через with():

$posts = Post::with('author')->get(); // лише 2 запити загалом

Вкладені та умовні зв'язки:

Post::with(['author', 'comments.user'])->get();        // вкладений eager
Post::with(['comments' => fn ($q) => $q->latest()])->get(); // умовний

Лічильники без завантаження зв'язку - withCount() (без N+1 і без вантаження самих рядків):

$posts = Post::withCount('comments')->get(); // доступ через $post->comments_count

Виявлення: Model::preventLazyLoading(! app()->isProduction()) у boot() кидає виняток на ледачих завантаженнях поза продакшеном; також допомагають Telescope і Debugbar.

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

Service Providers - центральне місце бутстрапу застосунку. Саме тут реєструються біндинги контейнера, слухачі подій, middleware, маршрути та публікація конфігів.

class AppServiceProvider extends ServiceProvider
{
    // лише біндинги в контейнер
    public function register(): void
    {
        $this->app->singleton(Parser::class);
    }

    // виконується після реєстрації всіх провайдерів
    public function boot(): void
    {
        Gate::define('admin', fn ($u) => $u->is_admin);
    }
}

Правило: у register() - лише біндинги (інші сервіси можуть бути ще не зареєстровані); у boot() - усе інше.

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

Провайдери завантажуються у дві фази, і плутанина між ними дає помилки, які важко пояснити.

register() - тільки привʼязки до контейнера. На цей момент інші провайдери ще не зареєстровані, тож звертатися до чужих сервісів не можна:

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

Замикання виконається пізніше - у момент першого резолву, коли все вже піднято.

boot() - усе інше. Викликається після того, як усі провайдери відпрацювали register(), тож тут доступні будь-які сервіси:

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());

    View::composer('layouts.app', SidebarComposer::class);

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

Типова помилка - зробити в register() щось на кшталт:

public function register(): void
{
    // Провайдер конфігурації міг ще не відпрацювати.
    $key = config('services.weather.key');
    $this->app->singleton(WeatherClient::class, fn () => new WeatherClient($key));
}

Значення читається негайно, і якщо потрібний провайдер ще не завантажився, отримаєте null без жодної помилки. Всередині замикання те саме читання безпечне.

Правило: register() - сказати контейнеру, як створювати; boot() - зробити щось із уже готовим застосунком.

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

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

Ознаки, що час розділяти:

  • у boot() упереміш макроси, правила Model::shouldBeStrict(), RateLimiter::for(), Gate::before(), Event::listen() і прив'язки інтеграцій;
  • щоб змінити одну інтеграцію, доводиться гортати весь файл;
  • різні частини належать різним модулям чи командам.

Як розділяють: за предметною областю, а не за типом коду.

// bootstrap/providers.php
return [
    App\Providers\AppServiceProvider::class,       // загальні налаштування фреймворку
    App\Providers\BillingServiceProvider::class,   // усе про оплату: шлюз, вебхуки, лімітери
    App\Providers\SearchServiceProvider::class,    // клієнт пошуку, індексатори
];

Провайдер модуля тримає прив'язки, події й політики свого модуля - тоді модуль можна зрозуміти, відкривши один файл.

Чого уникати:

  • провайдер на кожен рядок - десяток порожніх класів гірший за один охайний файл;
  • важкої роботи в boot() - він виконується на кожен запит, тож запит до бази чи HTTP-виклик там гальмує все;
  • залежності від порядку провайдерів у register(): там можна лише реєструвати, а користуватися сервісами - у boot().

Провайдер, що лише реєструє прив'язки, можна зробити відкладеним (DeferrableProvider).

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

Через властивості $bindings і $singletons замість викликів у register().

class AppServiceProvider extends ServiceProvider
{
    public array $bindings = [
        ServerProvider::class => DigitalOceanServerProvider::class,
    ];

    public array $singletons = [
        DowntimeNotifier::class => PingdomDowntimeNotifier::class,
        ServerToolsProvider::class => ServerToolsProvider::class,
    ];
}

Фреймворк сам пройде ці масиви, коли завантажуватиме провайдер, і зареєструє кожну пару «абстракція - реалізація».

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

Коли потрібен register(): якщо об'єкт треба створити по-особливому - з параметрами з конфігурації, з умовою, з замиканням:

public function register(): void
{
    $this->app->singleton(PaymentGateway::class, fn () => new StripeGateway(
        config('services.stripe.secret'),
    ));
}

Ще одна зручність: метод boot() теж підтримує впровадження залежностей - потрібні сервіси оголошують його параметрами, а не дістають через $this->app->make().

public function boot(ResponseFactory $response): void
{
    $response->macro('caps', fn (string $value) => $response->make(strtoupper($value)));
}

Докладніше в документації: Властивості bindings і singletons

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

event(new OrderShipped($order)); // диспатч

// слухач
class SendShipmentNotification
{
    public function handle(OrderShipped $event): void
    {
        // ...
    }
}
  • Слухача, що реалізує ShouldQueue, обробляють асинхронно в черзі.
  • У сучасному Laravel слухачі автоматично виявляються за type-hint у методі handle - ручна реєстрація не обов'язкова.

Приклад: подія UserRegistered → слухачі «надіслати лист», «нарахувати бонус», «оновити статистику».

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

Подія повідомляє, що щось сталося, не знаючи, хто на це зреагує. Це розвʼязує звʼязок між тим, хто діє, і тим, хто відповідає.

OrderPlaced::dispatch($order);

Слухачі - лист, нарахування бонусів, повідомлення на склад - підписані окремо, і додати ще одного можна, не чіпаючи оформлення замовлення.

Коли події доречні:

  • На одну дію треба кілька незалежних реакцій, і їхній перелік з часом росте.
  • Реакції належать іншим доменам - розсилка, аналітика, інтеграції.
  • Реакція має піти у фонову роботу (ShouldQueue на слухачі).
  • Реагувати мусить пакет чи модуль, який про ваш код не знає.

Коли краще виклик напряму:

  • Реакція єдина й завжди та сама. OrderPlaced з одним слухачем - це виклик методу, записаний у два файли.
  • Порядок і результат важливі: подія не повертає значення й не гарантує послідовності.
  • Дія - частина тієї самої операції, яка мусить відкотитися разом із нею.

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

Друга пастка - транзакції. Подія, відправлена всередині DB::transaction(), може дійти до черги раніше, ніж транзакція завершиться, - і воркер не знайде запису. Для цього є ShouldDispatchAfterCommit на події або afterCommit на завданні.

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

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

Бо подію диспетчеризували всередині транзакції, а воркер узяв завдання раніше, ніж транзакція закомітилася.

DB::transaction(function () use ($data) {
    $order = Order::create($data);
    OrderPlaced::dispatch($order);    // слухач у черзі вже може стартувати
    $order->items()->createMany($data['items']);
});

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

Рішення:

  • ShouldQueueAfterCommit у слухачі - стати в чергу лише після коміту;
  • ShouldDispatchAfterCommit у події - диспетчеризувати саму подію після коміту (не дістануть і синхронні слухачі);
  • after_commit => true у конфігурації підключення черги - для всіх завдань;
  • для спостерігачів - ShouldHandleEventsAfterCommit.

Якщо транзакції немає, усе це спрацьовує одразу, тож поведінка поза транзакцією не змінюється.

Те саме стосується листів і сповіщень у черзі - у них є afterCommit().

Докладніше в документації: Слухачі в черзі й транзакції

Два різні питання - «чи відправлено подію» і «чи правильно працює слухач» - тестують окремо.

Чи відправлено:

Event::fake([OrderShipped::class]);

$this->post("/orders/{$order->id}/ship")->assertOk();

Event::assertDispatched(OrderShipped::class, fn ($e) => $e->order->is($order));

Передавати список подій важливо: Event::fake() без аргументів підмінить усі події, зокрема події моделей. Тоді зламається все, що тримається на creating - генерація UUID, slug, спостерігачі - і тест впаде з дивною помилкою.

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

  • Event::fakeExcept([...]) - підмінити все, крім переліченого;
  • Event::fakeFor(fn () => ...) - лише для частини тесту;
  • Event::assertListening(OrderShipped::class, SendShipmentNotification::class) - що слухач підписаний.

Чи правильно працює слухач - окремий тест, де слухач викликають напряму:

(new SendShipmentNotification)->handle(new OrderShipped($order));

Notification::assertSentTo($order->customer, ShipmentSent::class);

Варто мати й кілька тестів без підмін, де ланцюжок «дія → подія → слухач» проходить цілком: саме там ламаються інтеграції, які фейки приховують.

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

Queue дозволяє відкласти важку роботу (Job) у фон, щоб не змушувати користувача чекати.

class ProcessPodcast implements ShouldQueue
{
    public function handle(): void { /* важка робота */ }
}

ProcessPodcast::dispatch($podcast)->onQueue('media');
  • Драйвери черг: database, redis, sqs (config/queue.php).
  • Воркер обробляє завдання: php artisan queue:work.
  • Підтримка повторів ($tries, backoff), затримок (->delay()), middleware для завдань.

Типове застосування: email, обробка зображень, виклики зовнішніх API, генерація звітів.

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

Обидва групують завдання, але з протилежною метою.

Ланцюжок (Bus::chain) - послідовність. Наступне завдання стартує лише після успіху попереднього; якщо одне впало, решта не виконується.

Bus::chain([
    new ProcessPodcast($podcast),
    new OptimizePodcast($podcast),
    new ReleasePodcast($podcast),
])->catch(fn (Throwable $e) => report($e))->dispatch();

Підходить, коли кроки залежать один від одного: не можна публікувати те, що ще не оброблено.

Пакет (Bus::batch) - паралельність. Завдання виконуються незалежно, кількома воркерами одночасно, а колбеки then, catch, finally спрацьовують, коли пакет завершено.

Bus::batch($users->map(fn ($u) => new SendNewsletter($u)))
    ->then(fn (Batch $batch) => Log::info('Розсилку завершено'))
    ->allowFailures()
    ->dispatch();

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

Деталі: пакетам потрібна таблиця job_batches (make:queue-batches-table), а завдання - з трейтом Batchable. Скасування пакета не зупиняє вже взяті завдання, тож кожне перевіряє $this->batch()->cancelled() на початку.

Докладніше в документації: Пакетна обробка завдань

Це обгортки навколо handle(), як middleware маршрутів навколо контролера. Вони виносять з завдання повторювану логіку: обмеження, блокування, умови пропуску.

public function middleware(): array
{
    return [
        new WithoutOverlapping($this->user->id),
        new RateLimited('external-api'),
    ];
}

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

  • WithoutOverlapping - не дає двом завданням з тим самим ключем виконуватися одночасно (оновлення балансу одного користувача).
  • RateLimited - тримає частоту звернень у межах ліміту, оголошеного через RateLimiter::for(); зайві завдання повертаються в чергу.
  • ThrottlesExceptions - якщо зовнішній сервіс почав падати, перестає його смикати на якийсь час.
  • Skip::when(...) - видаляє завдання без виконання за умовою, наприклад якщо замовлення вже скасоване.

Пастка: повернення в чергу через WithoutOverlapping чи RateLimited теж рахується як спроба. З однією спробою за замовчуванням таке завдання одразу потрапить у failed_jobs - тому для них збільшують #[Tries] або обмежують за часом.

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

Розвести їх по різних чергах і сказати воркеру, яку брати першою.

SendPasswordReset::dispatch($user)->onQueue('high');
SendNewsletter::dispatch($user);   // у default
php artisan queue:work --queue=high,default

Порядок у --queue - це пріоритет: поки в high є завдання, default чекає. Лист для скидання пароля більше не стоїть за п'ятдесятьма тисячами розсилки.

Що врахувати:

  • Голодування. Якщо high ніколи не порожніє, default не отримає жодного воркера. Надійніше дати важливим чергам окремі воркери, а не лише пріоритет.
  • Маршрутизація. У Laravel 13 Queue::route(SendPasswordReset::class, queue: 'high') у провайдері задає чергу для класу, тож не треба писати onQueue() в кожному місці.
  • Horizon дозволяє описати супервізорів з різною кількістю процесів на чергу й балансувати їх автоматично.

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

Вакансії Laravel рівня Middle

Усі вакансії Laravel Middle

Full-Stack PHP Developer

Full-Stack розробник на PHP/Laravel і Vue/Nuxt для платформи в B2B продажах. Розвивати backend на Laravel 12 з PostgreSQL та frontend на Nuxt 3, реалізовувати нові функції на базі Claude API. Вимоги: 3+ років комерційного досвіду, сильні навички PHP/Laravel, Vue.js/Nuxt 3, PostgreSQL, англійська Upper-Intermediate+.

Full Stack Developer (PHP / React) Middle / Middle+

Full Stack розробник для аналітичного продукту компанії з перевірки контрагентів та оцінки ризиків. Основна робота: підтримка та розвиток існуючої системи, рефакторинг legacy-коду, розробка бекенду на PHP/Laravel/Symfony і фронтенду на React, оптимізація баз даних MySQL та SQL-запитів, інтеграція AI-сервісів. Вимоги: 3+ років комерційної розробки, PHP 8.x, Laravel або Symfony, React, REST API, Docker, Git, Linux. Англійська B1+.

Global Logistics Нова
2 дні тому

Програміст PHP Full stack Developer

Розробник PHP (фулстек) для розробки та підтримки веб-додатків. Основний стек: PHP 7x/8x, MySQL, Yii 2.0, JavaScript, Vue.js, REST API. Вимоги: 3+ років досвіду на PHP, 2+ років з Yii 2.0, знання MySQL на просунутому рівні, Git, CSS, jQuery. Будуть плюсом: Laravel, Redis, WebSocket, RabbitMQ, Firebase. Робота над власними продуктами компанії (CRM логістичних процесів), робота у команді, code review.

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

Інші рівні
Junior 101 Senior 130