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

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

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

62 питань

Contextual binding дозволяє віддавати різні реалізації одного інтерфейсу залежно від класу, що його запитує.

$this->app->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('local'));

$this->app->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('s3'));

Тобто PhotoController отримає локальний диск, VideoController - S3, хоча обидва просять Filesystem.

Споріднені можливості:

  • giveTagged() - впорснути всі сервіси з певним тегом.
  • Прив'язка примітивів: ->needs('$apiKey')->give(config('services.x.key')).

Корисно, коли одна абстракція має кілька конфігурацій у різних частинах застосунку.

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

PHP-FPM - «shared nothing»: кожен запит стартує з чистого стану, наприкінці все звільняється. Просто, безпечно, але є оверхед бутстрапу фреймворку на кожному запиті.

Octane тримає застосунок у пам'яті між запитами → кратно вищий throughput і нижча латентність.

PHP-FPM Octane
Стан між запитами чистий зберігається
Throughput нижчий значно вищий
Ризик витоків стану немає є
Складність деплою проста вища (воркери, рестарти)

Ціна Octane: треба остерігатися «протікання» стану (статика, синглтони, глобальні змінні), правильно скидати/перезапускати воркери, уважно з пам'яттю. FPM лишається розумним дефолтом, доки немає потреби в екстремальній продуктивності.

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

Http - обгортка над Guzzle з розумними значеннями за замовчуванням, але саме за замовчуванням і ховаються проблеми.

Базовий виклик:

$response = Http::withToken($token)
    ->timeout(5)
    ->retry(3, 200)
    ->get('https://api.example/vacancies', ['page' => 1]);

if ($response->failed()) {
    // ...
}

$data = $response->json();

Чотири речі, без яких у прод виходити не варто:

1. Таймаут. За замовчуванням запит може висіти 30 секунд, тримаючи PHP-воркер. Чужий сервіс, що «підвис», кладе ваш застосунок, а не свій. timeout(5) і connectTimeout(2) обовʼязкові.

2. Повтори з паузою. retry(3, 200) рятує від разових збоїв, але повторювати можна лише ідемпотентні запити: повтор POST про створення платежу створить його двічі.

3. Обробка помилки. Http не кидає виняток на 4xx/5xx - повертає відповідь із failed() === true. Код, що одразу робить ->json()['id'], отримає null і піде далі, ніби все гаразд. Або перевіряйте явно, або ставте ->throw().

4. Ізоляція від збоїв. Виклик чужого API під час обробки запиту робить вашу доступність залежною від чужої. Такі виклики належать у чергу, а на повторювані збої добре лягає circuit breaker.

У тестах - Http::fake(), щоб мережі не було зовсім:

Http::fake(['api.example/*' => Http::response(['id' => 1], 200)]);
Http::assertSent(fn ($request) => $request->url() === 'https://api.example/vacancies');

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

  • Ресурсна модель URL: іменники в множині (/posts, /posts/{id}/comments), дія - через HTTP-метод, а не в URL.
  • Коректні статус-коди: 200/201/204, 422 (валідація), 401/403, 404, 429.
  • API Resources для відповіді - щоб відв'язати JSON від схеми БД і контролювати формат.
  • Версіонування (/v1) із самого старту.
  • Пагінація, фільтрація, сортування через query-параметри; не віддавати все одразу.
  • Consistent error format - єдина структура помилок (Laravel дає { "message": ..., "errors": {...} } для 422).
  • Автентифікація через Sanctum/Passport, rate limiting на маршрутах.
  • Idempotency для небезпечних повторюваних операцій (платежі).
  • Документація (OpenAPI/Scribe) і контрактні тести.

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

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

Що налаштовується в DNS:

  • SPF - перелік серверів, яким дозволено слати від імені домену.
  • DKIM - криптопідпис листа; без нього провайдер не може підтвердити, що лист не підроблено.
  • DMARC - політика на випадок, коли SPF чи DKIM не зійшлися.

Без цих трьох записів лист від нового домену з високою ймовірністю не дійде до вхідних.

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

  • Адреса відправника з власного домену. from виду noreply@gmail.com не пройде перевірку - домен у from має збігатися з тим, для якого налаштовані SPF і DKIM.
  • Транзакційне окремо від масового. Розсилка з поганим показником відкриттів псує репутацію домену, і за нею перестають доходити листи про відновлення пароля. Часто для розсилок беруть окремий піддомен.
  • Відписка. Для масових листів заголовок List-Unsubscribe обовʼязковий: без нього люди тиснуть «спам» замість відписки, і це найгірший сигнал.
  • Текстова версія. Лист без text/plain частина фільтрів вважає підозрілим.
  • Обробка bounce. Слати на адресу, що повертає помилку, місяцями - прямий шлях до блокування; провайдери дають вебхуки, які варто слухати.

Практично: транзакційну пошту віддають спеціалізованому сервісу (Mailgun, Postmark, SES), а не SMTP власного сервера, - вони тримають репутацію IP і дають статистику доставки. У Laravel це лише зміна MAIL_MAILER.

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

Одне з ключових архітектурних рішень. Контролери й моделі швидко «розпухають», тож логіку виносять в окремі класи.

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

Service-класи - групують пов'язану логіку домену:

class OrderService
{
    public function __construct(
        private PaymentGateway $gateway,
        private InventoryManager $inventory,
    ) {}

    public function place(User $user, Cart $cart): Order
    {
        return DB::transaction(function () use ($user, $cart) {
            $this->inventory->reserve($cart->items);
            $order = $user->orders()->create([/* ... */]);
            $this->gateway->charge($user, $cart->total);
            OrderPlaced::dispatch($order);

            return $order;
        });
    }
}

Action-класи (single-action) - один клас = одна операція. Дрібніша гранулярність, дуже тестовано:

class PlaceOrderAction
{
    public function handle(User $user, Cart $cart): Order { /* ... */ }
}

«Товсті» моделі - логіку, тісно пов'язану з даними самої моделі (скопи, аксесори, прості методи стану), доречно лишати в моделі. Складні міждоменні операції - у сервіси.

Рекомендації:

  • Контролер тонкий: запит → виклик сервісу/екшену → відповідь.
  • Складні операції з кількома моделями - у Service/Action із транзакцією.
  • Логіка одного агрегату - у моделі (скопи, обчислювані атрибути).
  • Не плодіть абстракцій передчасно - починайте простіше, виносьте за потреби.

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

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

paginate() - класична пагінація з номерами сторінок:

$posts = Post::latest()->paginate(15);

Виконує два запити: сам вибір і count(*) для загальної кількості сторінок.

simplePaginate() - те саме без підрахунку загальної кількості, лише «далі/назад». Прибирає дорогий count(*) на великій таблиці.

cursorPaginate() - гортання за значенням ключа, без offset:

$posts = Post::latest()->cursorPaginate(15);

Чому offset повільний. limit 15 offset 100000 не перестрибує рядки - база читає й відкидає сто тисяч, перш ніж віддати п'ятнадцять. Що глибша сторінка, то повільніше; на останніх сторінках великого списку це секунди.

Курсорна пагінація натомість запитує «наступні 15 після цього значення»:

where (published_at, id) < (?, ?) order by published_at desc, id desc limit 15

Час не залежить від глибини - за умови, що є індекс на колонках сортування.

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

Ціна курсора: немає номерів сторінок і переходу «на сторінку 7» - лише вперед і назад. Сортування має бути за унікальною чи доповненою id комбінацією, інакше рядки з однаковим значенням загубляться.

Що обирати: адмінка з номерами сторінок - paginate(); довга стрічка чи API - cursorPaginate(); проміжний варіант, де потрібні лише «далі/назад» - simplePaginate().

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

Спершу треба побачити самі запити, а не здогадуватися.

Подивитися, що виконується:

// AppServiceProvider::boot()
DB::listen(function ($query) {
    if ($query->time > 100) {
        Log::warning('Slow query', ['sql' => $query->sql, 'ms' => $query->time]);
    }
});

Разово по одному запиту допомагає ->toSql() і ->dd(). У розробці - Telescope, Debugbar, Clockwork.

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

Post::where('is_published', true)->orderByDesc('published_at')->explain()->dd();

У PostgreSQL шукайте Seq Scan на великій таблиці - це повний перебір; у MySQL - type: ALL і rows, близьке до розміру таблиці.

Найчастіші причини:

  • Немає індексу на колонці з where чи order by. Для складених умов порядок колонок в індексі має значення: індекс (is_published, published_at) працює для фільтра за is_published із сортуванням, а (published_at, is_published) - ні.
  • Функція на колонці вбиває індекс: whereRaw('lower(email) = ?') змусить перебір, поки немає функціонального індексу.
  • LIKE '%текст%' індексом не користується - для пошуку потрібен повнотекстовий індекс або окремий рушій.
  • OFFSET на глибоких сторінках: offset 100000 змушує базу відкинути сто тисяч рядків. Рятує курсорна пагінація - cursorPaginate().

Індекс не безкоштовний: він сповільнює запис і займає місце. Додавати варто за фактом виміряної проблеми, а не «на всяк випадок» на кожну колонку.

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

Обидва фільтрують за пов'язаною таблицею, але роблять це по-різному.

whereHas() будує підзапит EXISTS:

$posts = Post::whereHas('comments', function ($query) {
    $query->where('is_approved', true);
})->get();

Модель повертається одна й без дублікатів, зв'язок не витягується. Для простої перевірки «чи є хоч один» є коротший has('comments'), а для кількості - has('comments', '>=', 3).

join() зшиває таблиці в одному запиті:

$posts = Post::join('comments', 'comments.post_id', '=', 'posts.id')
    ->where('comments.is_approved', true)
    ->select('posts.*')
    ->distinct()
    ->get();

Різниця, яка вирішує:

  • join множить рядки: пост із десятьма коментарями повернеться десять разів, тому потрібен distinct() або groupBy - і разом з ними падає перевага в швидкості.
  • join дає доступ до колонок другої таблиці - сортувати чи вибирати за ними можна тільки так.
  • whereHas читається ближче до наміру й не ламає зв'язки моделі.

Про продуктивність. Поширене «join завжди швидший» невірне: сучасні планувальники виконують EXISTS ефективно й часто зупиняються на першому збігу, тоді як join матеріалізує всі пари. Різниця залежить від індексів і селективності - міряйте EXPLAIN, а не вгадуйте.

Практичне правило: фільтруєте за наявністю - whereHas; потрібні колонки пов'язаної таблиці для вибірки чи сортування - join.

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

Вбудованих каналів чотири - 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 простіший.

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

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

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

N+1 у колонках. Колонка зі звʼязком робить запит на рядок:

TextColumn::make('company.name')   // + 1 запит на кожен рядок

Лікується модифікацією запиту таблиці:

public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()->with(['company', 'technologies']);
}

Лічильники. ->counts('vacancies') замість завантаження всієї колекції заради count().

Дорогі обчислення в state(). Замикання виконується для кожного рядка, тож звернення до бази всередині - те саме N+1, лише написане інакше.

Сортування й пошук за звʼязком роблять join, і без індексу на зовнішньому ключі це помітно:

TextColumn::make('company.name')
    ->searchable()      // where по приєднаній таблиці
    ->sortable()

Що ще сповільнює адмінку:

  • ->paginated([10, 25, 50, 'all']) з опцією all на великій таблиці вивантажує все в памʼять одним кліком користувача.
  • Глобальний пошук по багатьох ресурсах виконує запит на кожен зареєстрований ресурс.
  • Мініатюри без кешу: ImageColumn, що генерує прев'ю на льоту, робить це для кожного рядка.
  • Опції Select без ->searchable() вивантажують усі записи довідника у форму.

Як шукати причину: Debugbar або Telescope на сторінці ресурсу покажуть кількість запитів. Таблиця на 25 рядків має вкладатися в одиниці запитів; десятки - ознака незавантаженого звʼязку.

Enum у коді змінюється легко, а от рядки, які вже лежать у базі, - ні. Саме тут зʼявляються помилки після деплою.

Найнебезпечніше - перейменувати кейс:

// було
case Middle = 'middle';

// стало
case Mid = 'mid';

Код збереться, а всі наявні рядки зі значенням middle перестануть кастуватися: ValueError: "middle" is not a valid backing value. Впаде не міграція, а звичайна сторінка.

Правильний порядок для перейменування:

  1. Додати новий кейс, лишивши старий.
  2. Міграцією перевести дані: Vacancy::where('level', 'middle')->update(['level' => 'mid']).
  3. Наступним релізом прибрати старий кейс.

Додати новий кейс - безпечно, якщо колонка varchar. Але коли в базі використано нативний тип enum, потрібна ще й міграція самої колонки, а Schema::table()->change() для нативних enum працює не в усіх драйверах - подекуди доводиться писати DB::statement().

Тому колонку під enum майже завжди роблять string: перелік живе в PHP, база зберігає рядок, і зміни не потребують ALTER на великій таблиці.

Захист від падіння на невідомому значенні:

// null замість винятку, коли в базі щось несподіване
$level = VacancyLevel::tryFrom($vacancy->getRawOriginal('level'));

І ще одне: якщо enum використовується у валідації через Rule::enum(), видалений кейс одразу зробить старі збережені записи невалідними при редагуванні - про це згадують уже після скарг користувачів.

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

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

Не так:

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 зазвичай тягне ще й демо-дані.

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

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

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

Усі вакансії Laravel Senior
Mantah Нова
Сьогодні

Backend Developer (PHP)

Backend розробник для e-commerce проектів та корпоративних порталів. Розробляє архітектуру, обирає стек технологій, розвиває та підтримує PHP-системи, інтегрує зовнішні сервіси (платежі, CRM/ERP, маркетплейси). Вимоги: 3+ років досвіду, PHP 8.x, Laravel/Symfony, Magento 2, WordPress/WooCommerce, REST API/GraphQL, MySQL, Redis, Git/Docker, Upper-Intermediate англійська.

Real Estate Bees Нова
Сьогодні

Backend Engineer (PHP/Laravel)

Розробник Backend на Laravel 12/PHP 8.2 для revenue-critical систем з REST API, бізнес-логікою, інтеграцією третіх сервісів і Redis-черг. Потрібен досвід 3+ років PHP/Laravel, знання Domain-Driven Design, PostgreSQL, тестування та AI-інструментів. Позиція передбачає архітектурну трансформацію з legacy-коду до domain-first структури, з відповідальністю від вимог до production.

Edvantis
4 дні тому

Senior Full-Stack Software Engineer (PHP/Laravel, Vue.js)

Розробник повного стеку для хмарної платформи управління дослідженнями. Розроблення функцій на PHP (Laravel) та Vue.js, побудова REST API, оптимізація продуктивності, написання тестів. Вимоги: міцні знання PHP та Laravel, досвід з Vue.js і JavaScript, англійська Upper-Intermediate+.

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

Інші рівні
Junior 52 Middle 57