Питання
Найпопулярніші питання з реальних Laravel/PHP співбесід для всіх рівнів
171 питань
Mailable - це лист. Notification - це повідомлення, яке може піти листом, у базу, в Slack чи кудись ще, і канал вибирається окремо від змісту.
Створення:
php artisan make:notification VacancyPublished
class VacancyPublished extends Notification
{
public function __construct(public Vacancy $vacancy)
{
}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Вашу вакансію опубліковано')
->line("«{$this->vacancy->title}» тепер видно всім.")
->action('Переглянути', route('vacancies.show', $this->vacancy));
}
/**
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return ['vacancy_id' => $this->vacancy->id];
}
}
Відправлення:
$user->notify(new VacancyPublished($vacancy));
// або багатьом
Notification::send($users, new VacancyPublished($vacancy));
Метод notify() доступний завдяки трейту Notifiable на моделі.
Що дає канал database: сповіщення зберігається в таблиці notifications, і з нього робиться «дзвіночок» в інтерфейсі - $user->unreadNotifications.
Коли достатньо Mailable: одноразовий лист без варіантів доставки - чек, звіт, підтвердження форми. Notification доречний, коли те саме повідомлення має йти різними каналами або користувач сам обирає, як його повідомляти.
Драйвер задається в config/cache.php і змінною CACHE_STORE. Вибір визначає не швидкість, а те, чи бачать процеси спільний кеш.
array - у памʼяті одного процесу, зникає після запиту. Стандартний драйвер для тестів: кеш не тягнеться між тестами й нічого не треба чистити.
file - файли в storage/framework/cache. Працює без жодного налаштування, тому зручний локально. На кількох серверах кожен матиме свій кеш, а очищення тегів файловий драйвер не підтримує.
database - таблиця в базі. Спільний для всіх серверів, але кожне читання - це запит до тієї самої бази, яку кеш і мав розвантажити.
redis / memcached - окремий сервер у памʼяті. Спільний для всіх процесів, витримує теги й атомарні блокування. Це стандартний вибір для проду.
Практичне правило: локально file, у тестах array, у проді redis.
Що важливо пам'ятати про різницю. Код, написаний під Redis, може мовчки не працювати на file:
Cache::tags(['posts'])->put('list', $posts, 600); // file-драйвер кине виняток
Блокування (Cache::lock()) файловий драйвер підтримує, але блокування живе на диску одного сервера - на кількох вузлах воно вже нічого не гарантує.
Тому середовища краще тримати на однаковому драйвері - інакше помилка знайдеться вже в проді.
Найпростіший пошук - фільтр за кількома колонками, і для більшості сайтів цього достатньо надовго.
Запит:
public function index(Request $request)
{
$term = $request->string('q')->trim()->toString();
$vacancies = Vacancy::query()
->when($term !== '', function ($query) use ($term) {
$query->where(function ($query) use ($term) {
$query->where('title', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%");
});
})
->paginate(15)
->withQueryString();
return view('vacancies.index', compact('vacancies'));
}
Дві деталі, на яких зазвичай помиляються:
1. Дужки навколо orWhere. Без вкладеного замикання умова «розклеїться»: where(A)->orWhere(B) разом з іншими фільтрами дасть фільтр AND A OR B, і пошук почне повертати чуже. Вкладене замикання групує умови правильно.
2. Порожній запит. Без when() пошук за порожнім рядком перетвориться на like '%%' - формально працює, але виконує зайву роботу.
Про безпеку: підставляти значення в where() безпечно - Eloquent параметризує запит. Небезпечним є лише whereRaw("title like '%{$term}%'") зі вставкою рядка напряму.
Коли цього перестане вистачати: like '%...%' не використовує індекс і не знає словоформ, тож на десятках тисяч записів пошук стає повільним, а «розробники» не знаходять «розробник». Тоді переходять на повнотекстовий індекс бази або Laravel Scout.
Контролер - це шар HTTP: він приймає запит, дістає з нього дані, викликає бізнес-логіку й формує відповідь. Сервіс - це сама бізнес-логіка, яка про HTTP нічого не знає.
Як виглядає змішування:
public function store(Request $request)
{
$order = Order::create($request->validated());
$order->items()->createMany($request->input('items'));
$total = collect($request->input('items'))->sum(fn ($i) => $i['price'] * $i['qty']);
$order->update(['total' => $total]);
Mail::to($order->email)->send(new OrderPlaced($order));
Http::post('https://warehouse.example/orders', $order->toArray());
return redirect()->route('orders.show', $order);
}
Тут порахунок суми, лист і виклик складу існують лише всередині HTTP-запиту.
Те саме з сервісом:
class PlaceOrder
{
public function handle(array $data): Order
{
return DB::transaction(function () use ($data) {
$order = Order::create($data);
$order->items()->createMany($data['items']);
$order->update(['total' => $this->total($data['items'])]);
OrderPlaced::dispatch($order);
return $order;
});
}
}
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
$order = $placeOrder->handle($request->validated());
return redirect()->route('orders.show', $order);
}
Що це дає:
- Ту саму логіку можна викликати з Artisan-команди, черги чи імпорту - не тільки з форми.
- Тестувати можна без HTTP-запиту: створили масив, викликали
handle(), перевірили результат. - Транзакція охоплює всю операцію цілком.
- Контролер знову читається за кілька секунд.
Не поспішайте. Для Post::create($request->validated()) окремий сервіс - зайвий шар. Виносити варто тоді, коли з'являється щось із трьох: кілька моделей в одній операції, потреба викликати логіку не з HTTP, або контролер перестав вміщатися на екран.
Обидва кажуть контейнеру, як створювати сервіс. Різниця - у тому, скільки разів це станеться.
bind() створює новий екземпляр щоразу:
$this->app->bind(ReportBuilder::class, function ($app) {
return new ReportBuilder($app->make(Connection::class));
});
app(ReportBuilder::class) === app(ReportBuilder::class); // false
singleton() створює один раз і повертає той самий обʼєкт до кінця запиту:
$this->app->singleton(WeatherClient::class, function ($app) {
return new WeatherClient(config('services.weather.key'));
});
app(WeatherClient::class) === app(WeatherClient::class); // true
Є ще scoped() - як singleton, але скидається на кожному запиті. Це важливо для Octane, де застосунок живе між запитами й звичайний singleton зберігав би стан довше, ніж треба.
Що обирати. Singleton доречний, коли створення дороге (клієнт із конфігурацією, зʼєднання) або коли стан має бути спільним. bind() - коли обʼєкт накопичує стан і два різних місця не повинні його ділити.
Головна пастка singleton - утримання стану. Сервіс, що накопичує щось усередині, віддасть ці дані наступному, хто його запросить:
class Basket
{
private array $items = [];
public function add(Item $item): void
{
$this->items[] = $item; // у singleton лишиться на весь запит
}
}
Під Octane це вже не «на запит», а між запитами - і дані одного користувача протікають до іншого. Тому в singleton кладуть сервіси без стану, а стан тримають явно.
PHP дозволяє оголошувати типи аргументів, повернення та властивостей - і перевіряє їх у рантаймі.
class VacancyService
{
public function __construct(
private VacancyRepository $repository,
) {
}
public function publish(int $id, ?string $comment = null): Vacancy
{
// ...
}
}
Що дає: помилка ловиться в момент виклику, а не через три шари; редактор підказує методи; статичний аналіз бачить невідповідності до запуску.
declare(strict_types=1) змінює поведінку перевірки. Без нього PHP приводить типи мовчки:
function repeat(int $times): string { /* ... */ }
repeat('5'); // без strict_types: '5' стане 5, викликається нормально
repeat('5'); // зі strict_types: TypeError
Саме мовчазне приведення небезпечне: 'abc' перетвориться на 0, а '5 котів' - на 5, і помилка проявиться далеко від місця, де виникла.
Важлива деталь: директива діє на файл, де вона написана, і стосується викликів з нього, а не в нього. Тому її ставлять у кожен файл, першим рядком після <?php.
Корисні типи PHP 8:
?string- рядок абоnull.int|string- обʼєднання типів.never- функція не повертає керування (кидає виняток чи завершує процес).
У Laravel-проєктах declare(strict_types=1) зазвичай вимагається стилем коду й перевіряється Pint.
Ресурс - це опис того, як модель виглядає в адмінці: таблиця, форма й сторінки навколо них.
php artisan make:filament-resource Vacancy --generate
Команда створює клас ресурсу, сторінки List/Create/Edit, а форму й таблицю виносить в окремі файли (Schemas/VacancyForm.php, Tables/VacancyTable.php). Прапорці --embed-schemas і --embed-table лишають їх усередині класу ресурсу.
Дві головні частини - форма й таблиця:
public static function form(Schema $schema): Schema
{
return $schema->components([
TextInput::make('title')
->required()
->maxLength(255),
Select::make('level')
->options(VacancyLevel::class)
->required(),
Toggle::make('is_published'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')->searchable()->sortable(),
TextColumn::make('company.name')->label('Компанія'),
IconColumn::make('is_published')->boolean(),
])
->filters([
SelectFilter::make('level')->options(VacancyLevel::class),
]);
}
Що це дає без додаткового коду: список із пошуком, сортуванням і пагінацією, форми створення й редагування з валідацією, масові дії, фільтри.
На чому побудовано: Livewire відповідає за реактивність без написання JS, Alpine - за дрібні взаємодії в браузері, Tailwind - за оформлення. Тому Filament - це PHP-код, а не окремий фронтенд-застосунок.
Що варто знати одразу: форма й таблиця - це звичайні PHP-масиви обʼєктів, тож видимість поля, опції списку чи доступність дії задаються замиканнями й можуть залежати від користувача та стану запису.
Repository - патерн, що ховає доступ до даних за інтерфейсом: код працює з PostRepositoryInterface, не знаючи, звідки беруться записи.
interface PostRepositoryInterface
{
public function published(): Collection;
}
class EloquentPostRepository implements PostRepositoryInterface
{
public function published(): Collection
{
return Post::where('is_published', true)->get();
}
}
Питання зі співбесіди зазвичай про те, чи це потрібно тут. Аргумент «щоб замінити ORM» на практиці не спрацьовує: Eloquent - це Active Record, його моделі пронизують увесь застосунок, і підміна сховища все одно означала б переписування. Заміни ORM у живому проєкті майже не трапляється.
Аргумент «щоб тестувати» теж слабкий у Laravel: є RefreshDatabase і фабрики, тож тест із реальною базою пишеться просто й перевіряє більше, ніж мок репозиторію.
Що при цьому втрачається: зникають скопи, ліниві зв'язки й with() у місці виклику - або репозиторій обростає методами на кожну комбінацію фільтрів, або починає повертати Builder, і абстракція протікає.
Коли Repository справді доречний:
- Дані живуть не в Eloquent - зовнішнє API, Elasticsearch, кілька джерел за одним інтерфейсом.
- Домен свідомо відокремлений від фреймворку (DDD), і моделі домену не є Eloquent-моделями.
Що зазвичай працює краще: локальні скопи для повторюваних вибірок і Action/Service-класи для операцій. Вони дають ту саму зібраність без прошарку, який дублює Eloquent.
Service Container (IoC-контейнер) керує залежностями класів і виконує dependency injection. Він уміє автоматично будувати об'єкти, рекурсивно резолвлячи їхні залежності через type-hints конструктора.
// біндинг інтерфейсу до реалізації (зазвичай у Service Provider)
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// тепер усюди, де просять PaymentGateway, прийде StripeGateway
public function __construct(private PaymentGateway $gateway) {}
bind()- нова реалізація щоразу.singleton()- один екземпляр на весь життєвий цикл запиту.app(PaymentGateway::class)- ручне резолвлення.
Це серце Laravel: контролери, middleware, події - усе резолвиться через контейнер.
N+1 виникає, коли ви завантажуєте N моделей одним запитом, а потім у циклі звертаєтесь до їхнього зв'язку - це генерує ще N запитів.
$posts = Post::all(); // 1 запит
foreach ($posts as $post) {
echo $post->author->name; // +1 запит на кожен пост → N запитів
}
Рішення - eager loading через with():
$posts = Post::with('author')->get(); // лише 2 запити загалом
Вкладені та умовні зв'язки:
Post::with(['author', 'comments.user'])->get(); // вкладений eager
Post::with(['comments' => fn ($q) => $q->latest()])->get(); // умовний
Лічильники без завантаження зв'язку - withCount() (без N+1 і без вантаження самих рядків):
$posts = Post::withCount('comments')->get(); // доступ через $post->comments_count
Виявлення: Model::preventLazyLoading(! app()->isProduction()) у boot() кидає виняток на ледачих завантаженнях поза продакшеном; також допомагають Telescope і Debugbar.
Service Providers - центральне місце бутстрапу застосунку. Саме тут реєструються біндинги контейнера, слухачі подій, middleware, маршрути та публікація конфігів.
class AppServiceProvider extends ServiceProvider
{
// лише біндинги в контейнер
public function register(): void
{
$this->app->singleton(Parser::class);
}
// виконується після реєстрації всіх провайдерів
public function boot(): void
{
Gate::define('admin', fn ($u) => $u->is_admin);
}
}
Правило: у register() - лише біндинги (інші сервіси можуть бути ще не зареєстровані); у boot() - усе інше.
Провайдери завантажуються у дві фази, і плутанина між ними дає помилки, які важко пояснити.
register() - тільки привʼязки до контейнера. На цей момент інші провайдери ще не зареєстровані, тож звертатися до чужих сервісів не можна:
public function register(): void
{
$this->app->singleton(WeatherClient::class, function ($app) {
return new WeatherClient(config('services.weather.key'));
});
}
Замикання виконається пізніше - у момент першого резолву, коли все вже піднято.
boot() - усе інше. Викликається після того, як усі провайдери відпрацювали register(), тож тут доступні будь-які сервіси:
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
View::composer('layouts.app', SidebarComposer::class);
Gate::define('publish', fn (User $user) => $user->is_editor);
}
Типова помилка - зробити в register() щось на кшталт:
public function register(): void
{
// Провайдер конфігурації міг ще не відпрацювати.
$key = config('services.weather.key');
$this->app->singleton(WeatherClient::class, fn () => new WeatherClient($key));
}
Значення читається негайно, і якщо потрібний провайдер ще не завантажився, отримаєте null без жодної помилки. Всередині замикання те саме читання безпечне.
Правило: register() - сказати контейнеру, як створювати; boot() - зробити щось із уже готовим застосунком.
Події дають слабке зв'язування: одна частина застосунку «оголошує», що щось сталося, інші - реагують, нічого не знаючи одна про одну.
event(new OrderShipped($order)); // диспатч
// слухач
class SendShipmentNotification
{
public function handle(OrderShipped $event): void
{
// ...
}
}
- Слухача, що реалізує
ShouldQueue, обробляють асинхронно в черзі. - У сучасному Laravel слухачі автоматично виявляються за type-hint у методі
handle- ручна реєстрація не обов'язкова.
Приклад: подія UserRegistered → слухачі «надіслати лист», «нарахувати бонус», «оновити статистику».
Подія повідомляє, що щось сталося, не знаючи, хто на це зреагує. Це розвʼязує звʼязок між тим, хто діє, і тим, хто відповідає.
OrderPlaced::dispatch($order);
Слухачі - лист, нарахування бонусів, повідомлення на склад - підписані окремо, і додати ще одного можна, не чіпаючи оформлення замовлення.
Коли події доречні:
- На одну дію треба кілька незалежних реакцій, і їхній перелік з часом росте.
- Реакції належать іншим доменам - розсилка, аналітика, інтеграції.
- Реакція має піти у фонову роботу (
ShouldQueueна слухачі). - Реагувати мусить пакет чи модуль, який про ваш код не знає.
Коли краще виклик напряму:
- Реакція єдина й завжди та сама.
OrderPlacedз одним слухачем - це виклик методу, записаний у два файли. - Порядок і результат важливі: подія не повертає значення й не гарантує послідовності.
- Дія - частина тієї самої операції, яка мусить відкотитися разом із нею.
Головна пастка - невидимість. Через рік ніхто не скаже, що саме відбувається під час оформлення замовлення: у коді видно один рядок, а реально виконуються сім слухачів у різних файлах. Налагодження зводиться до пошуку по всьому проєкту.
Друга пастка - транзакції. Подія, відправлена всередині DB::transaction(), може дійти до черги раніше, ніж транзакція завершиться, - і воркер не знайде запису. Для цього є ShouldDispatchAfterCommit на події або afterCommit на завданні.
Правило: починайте з прямого виклику. Переходьте на подію, коли реакцій стало більше однієї або вони належать іншій частині системи.
Queue дозволяє відкласти важку роботу (Job) у фон, щоб не змушувати користувача чекати.
class ProcessPodcast implements ShouldQueue
{
public function handle(): void { /* важка робота */ }
}
ProcessPodcast::dispatch($podcast)->onQueue('media');
- Драйвери черг:
database,redis,sqs(config/queue.php). - Воркер обробляє завдання:
php artisan queue:work. - Підтримка повторів (
$tries,backoff), затримок (->delay()), middleware для завдань.
Типове застосування: email, обробка зображень, виклики зовнішніх API, генерація звітів.
Питання з реальних співбесід Laravel і PHP - 171 питання у 41 темі, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 19 Архітектура 12 Database 10 Performance 7 Routing 5 Безпека 5 Blade 5 API 5
Готуєтесь до співбесіди не просто так: зараз на сайті 167 відкритих вакансій Laravel і PHP. Переглянути вакансії