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

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

Найпопулярніші питання з реальних Laravel/PHP співбесід для всіх рівнів

57 питань

API Resource - шар трансформації між Eloquent-моделлю та JSON-відповіддю. Дає повний контроль над структурою API, відв'язуючи її від схеми БД.

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => UserResource::make($this->whenLoaded('author')),
            'createdAt' => $this->created_at->toIso8601String(),
        ];
    }
}

return PostResource::collection($posts);
  • whenLoaded() додає зв'язок лише якщо він eager-завантажений (без N+1).
  • Resource Collections дозволяють додавати метадані (meta, links).

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

Broadcasting транслює серверні події на клієнт через WebSockets - для оновлень у реальному часі (чати, нотифікації).

class MessageSent implements ShouldBroadcast
{
    public function broadcastOn(): array
    {
        return [new PrivateChannel('chat.'.$this->roomId)];
    }
}

На клієнті Laravel Echo підписується на канал:

Echo.private(`chat.${roomId}`)
    .listen('MessageSent', (e) => console.log(e.message));

Сервер WebSockets - Laravel Reverb (офіційний), Pusher або Soketi. Канали бувають public, private (з авторизацією) і presence (зі списком учасників).

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

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

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

    public function rules(): array
    {
        return ['title' => ['required', 'max:255']];
    }
}

// $request - вже провалідовано
public function store(StorePostRequest $request)
{
    Post::create($request->validated());
}

Переваги: тонкі контролери, перевикористання правил, метод prepareForValidation() для нормалізації вводу, власні повідомлення в messages().

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

Класична скарга: користувач вибрав фільтри, перейшов на другу сторінку - і фільтри злетіли. Причина в тому, що посилання пагінації за замовчуванням несуть лише 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. Якщо їх багато, сторінки з фільтрами зазвичай закривають від індексації, лишаючи в ній базовий список.

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

Валідацію можна писати просто в контролері:

public function store(Request $request)
{
    $data = $request->validate([
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required'],
    ]);
}

Для двох правил цього досить. Коли їх десяток, а та сама форма ще й редагується, контролер розпухає, а правила дублюються між store() і update().

Form Request виносить це в окремий клас:

php artisan make:request StorePostRequest
class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    /**
     * @return array<string, list<string>>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'published_at' => ['nullable', 'date'],
        ];
    }

    public function messages(): array
    {
        return ['title.required' => 'Вкажіть заголовок вакансії.'];
    }
}

Використання - тип у сигнатурі:

public function store(StorePostRequest $request)
{
    // сюди виконання дійде лише з валідними даними
    $post = Post::create($request->validated());
}

Що це дає: валідація й перевірка прав відбуваються до входу в метод; контролер лишається про свою справу; правила лежать в одному місці й перевикористовуються; prepareForValidation() дозволяє нормалізувати вхід (обрізати пробіли, привести формат) до перевірки.

Деталь, яку часто пропускають: authorize(), що повертає false, дає 403 - тобто 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 не варто вписувати в скрипти «щоб не заважало».

Практика, що рятує:

  • Перевіряйте відкат локально одразу після написання: migratemigrate:rollback --step=1migrate. Половина міграцій має неробочий down(), і виявляється це в найгірший момент.
  • Пишіть down() чесно, а якщо відкат неможливий - хай кидає виняток, це краще за мовчазну порожню реалізацію.
  • Не редагуйте вже застосовану міграцію - додавайте нову. Виправлений файл не перезастосується там, де він уже відпрацював.
  • Видалення колонки й перейменування - окремими релізами від коду, що їх читає, інакше деплой ламає працюючий застосунок у проміжку.

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

Dependency Injection - патерн, за якого клас отримує залежності ззовні (зазвичай через конструктор), а не створює їх сам. Це знижує зв'язування й полегшує тестування (можна підсунути мок).

class OrderController
{
    public function __construct(
        private PaymentGateway $gateway, // інжектується контейнером
    ) {}
}

У Laravel DI працює «з коробки»: контейнер читає type-hints і автоматично будує граф залежностей. Інжектити можна й у методи контролера (method injection), зокрема сам Request.

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

Індекс - структура (зазвичай 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;

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

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

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

Звичайне 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

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

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

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

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

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

  • 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

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

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

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

Full Stack розробник для підтримки та розвитку аналітичного продукту перевірки контрагентів. Робота зі складною бізнес-логікою, базами даних та інтеграцією AI-рішень. Стек: PHP 8.x (Laravel/Symfony), React, MySQL, REST API. Вимоги: 3+ років комерційного досвіду, глибоке розуміння SQL, Git, Docker, CI/CD, Linux, OWASP.

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

Програміст PHP (інтерн)

Вакансія на посаду інтерна PHP розробника для початківців з теоретичною базою ООП та базовими знаннями PHP. Потрібні навички Git/GitHub, власні проєкти. Стажування в офісі під керівництвом менторів з перспективою переходу на посаду Junior разробника.

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

Інші рівні
Junior 52 Senior 62