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

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

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

378 питань

Перетворити один зі списків на «словник» за ключем - тоді пошук стає миттєвим.

// O(n × m): для кожного замовлення шукаємо клієнта перебором
foreach ($orders as $order) {
    $customer = $customers->firstWhere('id', $order->customer_id);
}

// O(n + m): один раз будуємо індекс
$customersById = $customers->keyBy('id');

foreach ($orders as $order) {
    $customer = $customersById[$order->customer_id] ?? null;
}

На 5000 замовлень і 5000 клієнтів перший варіант - до 25 мільйонів порівнянь, другий - 10 тисяч операцій.

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

  • keyBy('id') - один елемент на ключ;
  • groupBy('customer_id') - кілька елементів на ключ (усі замовлення клієнта);
  • mapWithKeys() - довільні пари ключ-значення;
  • pluck('name', 'id') - словник «id => назва» для випадаючих списків.

Але спершу питання: чи потрібно зіставляти в PHP? Якщо обидва списки з однієї бази, зв'язок з with() чи join зробить це ефективніше й без завантаження зайвого. keyBy() - для даних з різних джерел: API, файл імпорту, інша база.

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

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

// звичайно
$users->each(fn (User $user) => $user->markAsVip());
$total = $orders->sum(fn (Order $order) => $order->total);
$active = $users->filter(fn (User $user) => $user->isActive());

// повідомленнями вищого порядку
$users->each->markAsVip();
$total = $orders->sum->total;
$active = $users->filter->isActive();

Працює для each, map, filter, reject, sum, avg, max, min, sortBy, groupBy, keyBy, first, contains, every, partition, unique та інших.

Коли доречно: коротка операція над кожним елементом, і читається як речення.

Коли ні:

  • потрібні аргументи чи умова - звичайне замикання зрозуміліше;
  • статичний аналіз і IDE гірше розуміють ->each->method(), тож у кодовій базі з суворим PHPStan частина команд їх уникає.

Поряд - макроси: якщо одну й ту саму операцію пишуть у багатьох місцях, її оформлюють як Collection::macro('toCsv', ...) у провайдері чи як метод власної колекції моделі.

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

Custom Cast інкапсулює логіку перетворення атрибута між форматом БД та об'єктом PHP.

class Money implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): MoneyValue
    {
        return new MoneyValue($value); // з БД → Value Object
    }

    public function set($model, $key, $value, $attributes): array
    {
        return ['price' => $value->cents]; // VO → у БД
    }
}

protected $casts = ['price' => Money::class];

Застосування: робота з Value Objects, шифрування полів, JSON-структури. Вбудовані касти: array, encrypted, datetime, enum-класи, AsCollection.

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

Laravel має тестування з коробки поверх PHPUnit; популярна надбудова - Pest із лаконічним синтаксисом.

it('creates a post', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/posts', [
        'title' => 'Hello',
    ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('posts', ['title' => 'Hello']);
});
  • Feature-тести перевіряють HTTP-флоу (більшість тестів); Unit - окремі класи ізольовано.
  • Трейт RefreshDatabase ізолює тести, відкочуючи зміни після кожного; дані генерують фабрики.
  • actingAs($user) автентифікує користувача для захищених маршрутів.

Корисні асерції: assertStatus, assertSee, assertJson, assertDatabaseHas, assertRedirect, assertAuthenticated.

Мокінг - ізоляція від зовнішніх ефектів (API, листи, черги):

Mail::fake();         Mail::assertSent(OrderShipped::class);
Queue::fake();        Queue::assertPushed(ProcessPodcast::class);
Notification::fake(); Http::fake();

// мок сервісу через контейнер
$this->mock(PaymentGateway::class)
    ->shouldReceive('charge')->once()->andReturn(true);

Запуск: php artisan test, --filter=PostTest, --parallel (швидше).

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

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

it('notifies the customer when the order ships', function () {
    Notification::fake();
    Mail::fake();
    Queue::fake();

    $order = Order::factory()->create();
    $order->ship();

    Notification::assertSentTo($order->customer, OrderShipped::class);
    Mail::assertSent(InvoiceMail::class, fn ($mail) => $mail->hasTo($order->customer->email));
    Queue::assertPushed(SyncWithWarehouse::class);
});

Які фейки є: Mail, Notification, Queue, Bus (бачить ще й ланцюжки й пакети), Event, Storage, Http, Process, Exceptions.

Пастки:

  • Event::fake() без аргументів глушить усі події, включно з подіями моделей, на яких можуть триматися генерація UUID чи Observer. Підробляйте лише потрібні: Event::fake([OrderShipped::class]).
  • Фейк не перевіряє, що робота виконується. Queue::assertPushed() доводить, що завдання поставлено, але не що його handle() працює. Код самого завдання тестують окремо - викликаючи його напряму.
  • Mailable можна перевірити без відправки: (new InvoiceMail($order))->assertSeeInHtml($order->number).

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

HTTP-тести Laravel мають методи для JSON-запитів і перевірки відповіді.

it('creates a post via the API', function () {
    Sanctum::actingAs(User::factory()->create(), ['posts:write']);

    $this->postJson('/api/posts', ['title' => 'Черги в Laravel'])
        ->assertCreated()
        ->assertJsonPath('data.title', 'Черги в Laravel')
        ->assertJsonStructure(['data' => ['id', 'title', 'created_at']]);
});

it('rejects a post without a title', function () {
    Sanctum::actingAs(User::factory()->create(), ['posts:write']);

    $this->postJson('/api/posts', [])
        ->assertUnprocessable()
        ->assertInvalid(['title']);
});

Що варто знати:

  • postJson() ставить Accept: application/json, тож помилки валідації приходять як 422 з JSON, а не редиректом.
  • assertJsonPath() перевіряє одне значення за шляхом, assertJson() - частковий збіг, assertExactJson() - точний.
  • Флюентні перевірки дозволяють описати структуру з типами й гарантувати, що зайвих полів немає:
->assertJson(fn (AssertableJson $json) => $json
    ->where('data.id', $post->id)
    ->whereType('data.title', 'string')
    ->missing('data.author.password')
    ->etc());
  • Автентифікація: actingAs() для сесії, Sanctum::actingAs() з переліком здатностей для токенів.

Докладніше в документації: Тестування JSON API

Керувати часом застосунку з тесту, а не чекати.

it('expires a subscription after 30 days', function () {
    $this->freezeTime();

    $subscription = Subscription::factory()->create();

    $this->travel(31)->days();

    expect($subscription->fresh()->isExpired())->toBeTrue();
});

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

  • freezeTime() - зупиняє now(), щоб час не зсувався між рядками тесту.
  • travel(5)->minutes(), travelTo($date), travelBack() - зсувають час застосунку.
  • Те саме вміє Carbon::setTestNow().

Що варто винести з цього: такий тест працює, лише поки код бере час через now() чи Carbon. Виклики time(), date() чи new \DateTime() бачать справжній годинник і тест обійдуть.

Чистіший шлях для доменного коду - передавати момент явно:

public function isExpired(CarbonImmutable $now): bool
{
    return $this->ends_at->lessThan($now);
}

Тоді тест взагалі не потребує підміни часу, а метод стає чистою функцією. Корисні випадки для перевірки: межі (рівно 30 днів), перехід доби, часові пояси й літній час.

Докладніше в документації: Робота з часом

Підмінити HTTP-клієнт Laravel фейковими відповідями, щоб тест не залежав від мережі й чужого сервісу.

it('imports exchange rates', function () {
    Http::fake([
        'api.rates.example/*' => Http::response(['usd' => 41.2], 200),
        '*' => Http::response(status: 500),
    ]);

    (new ImportRates)->handle();

    expect(Rate::latest()->value('usd'))->toBe(41.2);

    Http::assertSent(fn (Request $request) => str_contains($request->url(), 'api.rates.example')
        && $request->hasHeader('Authorization'));
});

Що варто знати:

  • Шаблони URL з * підмінюють групи запитів. Запит, що не збігся з жодним шаблоном, піде в мережу насправді.
  • Http::preventStrayRequests() забороняє такі запити: будь-який непідмінений кидатиме виняток. Його вмикають у TestCase чи Pest.php для всього набору.
  • Http::sequence() повертає різні відповіді на послідовні виклики - так перевіряють повтори після помилки.
  • Помилки теж тестують: таймаут, 429, 500 - саме там зазвичай ховаються баги обробки.

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

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

Тест не повинен слати справжні листи - це повільно, ненадійно й іноді доходить до реальних людей.

Mail::fake() підміняє транспорт і запамʼятовує, що мало піти:

it('sends a welcome letter on registration', function () {
    Mail::fake();

    $this->post('/register', [
        'email' => 'dev@example.com',
        'password' => 'password',
    ]);

    Mail::assertSent(WelcomeMail::class, function (WelcomeMail $mail) {
        return $mail->hasTo('dev@example.com');
    });
});

Є ще assertNotSent(), assertNothingSent() і assertSentCount().

Важлива пастка: якщо лист відправляється через сповіщення ($user->notify(...)), Mail::fake() його не побачить - потрібен Notification::fake() і assertSentTo(). Плутанина між цими двома - найчастіша причина «тест не бачить листа, хоча він точно йде».

Друга пастка: якщо mailable реалізує ShouldQueue, а в тесті стоїть Queue::fake(), лист не дійде до пошти взагалі - перевіряти треба постановку завдання.

Поза тестами для перегляду верстки зручні два інструменти. Драйвер log пише лист у storage/logs, а Mailpit чи Mailtrap ловлять пошту в локальний ящик - листи виглядають як справжні, але нікуди не йдуть.

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

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

Їх часто плутають, бо обидва створюють записи. Різниця в тому, що саме вони створюють.

Фабрика описує, як виглядає один правдоподібний запис:

class VacancyFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => fake()->jobTitle(),
            'level' => fake()->randomElement(VacancyLevel::cases()),
            'is_published' => true,
        ];
    }

    public function draft(): static
    {
        return $this->state(fn () => ['is_published' => false]);
    }
}

Використовується переважно в тестах, де потрібен запис із конкретною властивістю:

$vacancy = Vacancy::factory()->draft()->create();

Сідер заповнює базу набором даних - і зазвичай викликає фабрики:

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call(RolesSeeder::class);       // довідник: конкретні значення
        Vacancy::factory()->count(50)->create(); // демо-дані: випадкові
    }
}

Коли що потрібне:

  • Довідники - ролі, категорії, статуси, налаштування. Це реальні дані застосунку, у них немає випадковості, і вони мають бути ідемпотентними: firstOrCreate() або updateOrCreate(), щоб повторний запуск не плодив дублів.
  • Демо-дані для локальної розробки - фабрики всередині сідера.
  • Тести - фабрики напряму, без сідера: кожен тест створює рівно те, що перевіряє.

Головне правило: сідер із довідником має бути безпечним для повторного запуску, бо його виконують і на проді. Сідер з демо-даними на прод не потрапляє взагалі.

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

Через трейт WithoutModelEvents у сідері.

class UserSeeder extends Seeder
{
    use WithoutModelEvents;

    public function run(): void
    {
        User::factory()->count(1000)->create();
    }
}

Без нього кожен створений користувач запускає спостерігачів: вітальний лист, запис в аналітику, синхронізацію з CRM. Тисяча тестових користувачів - тисяча листів, а локально ще й довге очікування.

Деталі:

  • трейт діє й на сідери, викликані через $this->call();
  • DatabaseSeeder у свіжому застосунку вже має цей трейт;
  • для частини коду - Model::withoutEvents(fn () => ...).

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

Для повної ізоляції від зовнішнього світу під час локального сідування допомагає ще й MAIL_MAILER=log у .env.

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

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

public function run(): void
{
    User::factory()
        ->count(50)
        ->has(Post::factory()->count(3)->published())
        ->create();

    User::factory()->admin()->create(['email' => 'admin@example.test']);
}

Що робить дані корисними для розробки:

  • стани фабрики (published(), admin(), suspended()) - видно крайні випадки, а не лише «середній» запис;
  • зв'язки через has() і for() - сторінки показують реальну структуру;
  • Sequence - чергування значень: половина активних, половина ні;
  • відомий обліковий запис для входу - з фіксованим email і паролем з .env.example.

Відтворюваність: fake()->seed(1234) на початку робить згенеровані дані однаковими між запусками - зручно, коли багу треба відтворити на тих самих даних.

Швидкість: тисячі записів через create() повільні, бо кожен - окремий INSERT з подіями. Для великих обсягів беруть WithoutModelEvents, транзакцію навколо сідування або insert() пачками з масивів, згенерованих make()->toArray().

Докладніше в документації: Використання фабрик моделей

Підзапит - запит, вкладений в інший. Eloquent дозволяє вставляти їх у select, where, orderBy.

// додати останню дату входу кожного користувача одним запитом
User::addSelect(['last_login_at' => Login::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
    ->limit(1),
])->get();

// сортування за підзапитом
Destination::orderByDesc(
    Flight::select('arrived_at')->whereColumn('destination_id', 'destinations.id')->latest()->limit(1)
)->get();

Підзапити допомагають уникнути N+1 і зайвих операцій JOIN, обчислюючи похідні значення в межах одного запиту.

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

Scout - це загальний інтерфейс до пошукових рушіїв: модель позначається трейтом, а рушій (Meilisearch, Algolia, Typesense, база) підмінюється конфігурацією.

class Vacancy extends Model
{
    use Searchable;

    /**
     * @return array<string, mixed>
     */
    public function toSearchableArray(): array
    {
        return [
            'title' => $this->title,
            'description' => $this->description,
            'company' => $this->company?->name,
        ];
    }
}

Vacancy::search('laravel middle')->paginate(15);

Індексація відбувається автоматично на збереженні моделі, а масово - через php artisan scout:import.

Коли вистачить LIKE:

  • Пошук по одній-двох колонках, записів - тисячі.
  • Достатньо точного входження, без урахування словоформ.
  • Не хочеться ще одного сервісу в інфраструктурі.
Vacancy::where('title', 'like', "%{$term}%")->get();

Де LIKE перестає працювати:

  • Не використовує індекс із % на початку - на великій таблиці це повний перебір.
  • Не знає словоформ. «розробник» не знайдеться за запитом «розробники», і жодних синонімів.
  • Не ранжує. Збіг у заголовку та згадка в кінці опису однакові за вагою.
  • Не прощає помилок - один зайвий символ, і результат порожній.
  • Не шукає по кількох сутностях одразу.

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

Практичне правило: починайте з LIKE, переходьте на повнотекстовий індекс, коли обсяг виріс, і на Scout - коли потрібні релевантність, помилки в запиті й фасети.

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

Крім кешу конфігурації й маршрутів, php artisan optimize створює ще два кеші, про які часто забувають.

event:cache - мапа подій і слухачів.

Laravel може сам знаходити слухачів: сканує каталог app/Listeners і за типом параметра handle(OrderShipped $event) визначає, на яку подію слухач підписаний. Зручно, але сканування файлів і Reflection на кожен запит - зайва робота.

php artisan event:cache    # записати мапу в bootstrap/cache/events.php
php artisan event:clear

Після кешування Laravel бере мапу з файлу, нічого не скануючи.

Коли поводиться неочікувано:

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

view:cache - скомпільовані шаблони Blade.

Blade компілює кожен шаблон у звичайний PHP-файл у storage/framework/views при першому зверненні й перекомпільовує, якщо шаблон змінився. view:cache компілює всі шаблони заздалегідь:

  • перший запит після деплою не витрачає час на компіляцію;
  • помилка синтаксису Blade виявляється під час деплою, а не в користувача.

Коли поводиться неочікувано:

  • права: скомпільовані файли створені від root під час деплою, а PHP-FPM працює від www-data - при спробі перекомпілювати виникає помилка доступу;
  • кілька серверів чи контейнерів - кеш формується на кожному окремо, і він має відповідати коду саме цього сервера;
  • змінені шаблони без очищення кешу в нестандартному деплої (файли змінено на місці) - Blade перевіряє час зміни файлів, але з OPcache і validate_timestamps=0 старий скомпільований PHP може лишитися в пам'яті.

Загальне правило для всіх кешів: у деплої спершу оновити код і залежності, потім php artisan optimize (конфігурація, маршрути, події, представлення), потім перезапустити процеси, що тримають код у пам'яті (PHP-FPM reload чи OPcache reset, воркери черг, Octane). А локально - php artisan optimize:clear, якщо поведінка не відповідає коду.

Що не кешується цими командами: дані застосунку (Cache::), відповіді й запити - це окремий рівень, і optimize:clear його не чіпає.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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