Питання
Найпопулярніші питання з реальних Laravel/PHP співбесід для всіх рівнів
171 питань
Реплікація розвантажує основний сервер: запис іде на primary, читання - на replicas. Laravel маршрутизує запити автоматично, якщо в конфізі з'єднання задані секції read/write:
'mysql' => [
'read' => ['host' => ['10.0.0.2', '10.0.0.3']], // репліки
'write' => ['host' => ['10.0.0.1']], // primary
'sticky' => true,
// ...спільні параметри
],
SELECT→ репліка,INSERT/UPDATE/DELETE→ primary.sticky => trueкритично важливе: після запису в межах того ж запиту читання теж піде з primary, інакше через replication lag можна прочитати застарілі дані.- Реплікація асинхронна → завжди закладайте можливе відставання реплік у логіці.
Файл, покладений у storage/app, недоступний ззовні - це правильно за замовчуванням. Питання в тому, як віддати його тому, кому можна.
Що робити не варто: класти приватні файли в public/ чи публічний бакет і покладатися на те, що адресу ніхто не вгадає. Посилання розходяться, індексуються й лишаються робочими назавжди.
Спосіб 1 - віддати через контролер із перевіркою прав:
public function download(Document $document)
{
$this->authorize('view', $document);
return Storage::disk('private')->download($document->path, $document->name);
}
Кожне звернення проходить авторизацію. Мінус - файл іде крізь PHP, тож на великих файлах воркер зайнятий увесь час передачі.
Спосіб 2 - тимчасове підписане посилання до S3-сумісного сховища:
$url = Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(5));
Посилання підписане й саме перестає працювати після строку. Файл віддає сховище напряму, PHP у передачі не бере участі - це те, що потрібно для великих файлів.
Спосіб 3 - підписаний маршрут, коли сховище тимчасових URL не вміє:
URL::temporarySignedRoute('documents.download', now()->addMinutes(5), ['document' => $document->id]);
Middleware signed перевіряє підпис і строк.
Про що часто забувають:
- Спосіб 1 без
authorize()перетворюється на публічний доступ через власний контролер. - Строк життя посилання має бути коротким: доки воно живе, ним може скористатися будь-хто, кому воно потрапило.
- Імʼя файла від користувача не можна підставляти в шлях -
Storage::putFile()генерує безпечне імʼя сам.
Cache Stampede (dogpile) - коли популярний ключ кешу протермінувався, і сотні одночасних запитів кидаються перераховувати важке значення одночасно, перевантажуючи БД.
Рішення в Laravel:
// блокування: лише один процес перераховує, інші чекають результат
$value = Cache::lock('report:lock', 10)->block(5, function () {
return Cache::remember('report', 3600, fn () => $this->heavyReport());
});
Інші стратегії:
Cache::flexible()(stale-while-revalidate) - віддає «протухле» значення, поки одне фонове оновлення його перераховує.- Розмазування TTL (jitter), щоб ключі не протухали одночасно.
- Прогрів кешу (cache warming) за розкладом, а не за запитом користувача.
Завдання можуть падати через тимчасові збої (мережа, rate limit) - потрібна стратегія повторів і обробки остаточних провалів.
class CallApi implements ShouldQueue
{
public int $tries = 5; // спроб
public int $maxExceptions = 2;
public int $timeout = 30;
// прогресивна затримка між спробами
public function backoff(): array
{
return [10, 30, 60]; // 10с, 30с, 60с...
}
public function failed(Throwable $e): void
{
// викликається після вичерпання спроб
}
}
- Остаточно провалені завдання осідають у таблиці
failed_jobs. php artisan queue:retry all- повторити,queue:flush- очистити.releaseAfter,WithoutOverlapping,RateLimitedmiddleware керують поведінкою.- Ідемпотентність (idempotency) обов'язкова - бо завдання може виконатися повторно.
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')).
Корисно, коли одна абстракція має кілька конфігурацій у різних частинах застосунку.
PHP-FPM - «shared nothing»: кожен запит стартує з чистого стану, наприкінці все звільняється. Просто, безпечно, але є оверхед бутстрапу фреймворку на кожному запиті.
Octane тримає застосунок у пам'яті між запитами → кратно вищий throughput і нижча латентність.
| PHP-FPM | Octane | |
|---|---|---|
| Стан між запитами | чистий | зберігається |
| Throughput | нижчий | значно вищий |
| Ризик витоків стану | немає | є |
| Складність деплою | проста | вища (воркери, рестарти) |
Ціна Octane: треба остерігатися «протікання» стану (статика, синглтони, глобальні змінні), правильно скидати/перезапускати воркери, уважно з пам'яттю. FPM лишається розумним дефолтом, доки немає потреби в екстремальній продуктивності.
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');
- Ресурсна модель 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) і контрактні тести.
Більшість причин лежить поза кодом - у 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 із транзакцією.
- Логіка одного агрегату - у моделі (скопи, обчислювані атрибути).
- Не плодіть абстракцій передчасно - починайте простіше, виносьте за потреби.
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.
Вбудованих каналів чотири - 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 завжди явний.
Питання з реальних співбесід Laravel і PHP - 171 питання у 41 темі, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 19 Архітектура 12 Database 10 Performance 7 Routing 5 Безпека 5 Blade 5 API 5
Готуєтесь до співбесіди не просто так: зараз на сайті 167 відкритих вакансій Laravel і PHP. Переглянути вакансії