Питання на співбесіді з 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, файл імпорту, інша база.
Скорочення для частих замикань, які лише викликають метод чи беруть поле елемента.
// звичайно
$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.
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()з переліком здатностей для токенів.
Керувати часом застосунку з тесту, а не чекати.
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. Коли провайдер змінить формат, тести лишаться зеленими - тому корисні контрактні перевірки чи хоча б моніторинг справжніх відповідей у проді.
Тест не повинен слати справжні листи - це повільно, ненадійно й іноді доходить до реальних людей.
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, обчислюючи похідні значення в межах одного запиту.
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 - коли потрібні релевантність, помилки в запиті й фасети.
Крім кешу конфігурації й маршрутів, 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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 147 відкритих вакансій Laravel і PHP. Переглянути вакансії