Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Міграції виконуються один раз на базу, а не на кожному сервері.
Як цього досягти:
- окремий крок деплою (job у CI, init-контейнер, release-фаза) замість
migrateу старті кожного контейнера; - якщо запускають з кожного сервера -
php artisan migrate --isolated --force: перший бере атомарне блокування в кеші, інші тихо завершуються. Кеш має бути спільним; --forceпотрібен, щоб команда не питала підтвердження на продакшені.
Сумісність схеми й коду. Під час rolling-деплою одночасно працюють стара й нова версії коду, тож міграція має підходити обом:
- Розширення: додати нову колонку (nullable чи з дефолтом), код пише в обидві.
- Перенесення даних фоново порціями.
- Перемикання коду на нову колонку.
- Звуження: видалити стару колонку окремим наступним релізом.
Що ще враховують:
- довгі блокування на великих таблицях:
instant()на MySQL,online()для індексів на PostgreSQL, нічні вікна для важкого; - PostgreSQL обгортає міграцію в транзакцію, а
CREATE INDEX CONCURRENTLYу транзакції не працює; - сідери довідників - окремо від міграцій і ідемпотентні.
Сотні файлів сповільнюють створення бази для тестів і CI, а історія давно не потрібна покроково.
php artisan schema:dump --prune
Команда записує поточну схему в database/schema/{connection}-schema.sql і з --prune видаляє наявні міграції. Нова база спершу виконує SQL зі схеми, а потім - міграції, створені після дампу.
На що зважати:
- Різні СУБД. Дамп робиться для конкретного підключення. Якщо тести йдуть на іншій базі (SQLite замість PostgreSQL), потрібен дамп і для неї - інакше тести не зберуть схему.
- Інструменти. Для дампу потрібні
mysqldumpчиpg_dumpна машині, де виконується команда. - Наявні середовища не зачіпаються: продакшен уже має всі ці міграції в таблиці
migrations. - Дані в міграціях. Якщо старі міграції наповнювали довідники, після стискання цих даних у новій базі не буде - їх переносять у сідери.
Альтернатива без стискання - тримати міграції, але прискорити тести: RefreshDatabase мігрує базу один раз на прогін і далі працює транзакціями, тож кількість міграцій впливає лише на старт.
Довгу команду переривають деплой, зупинка контейнера, OOM. Вона має завершуватися чисто й продовжувати, а не починати все з нуля чи дублювати роботу.
1. Реагувати на сигнал:
public function handle(): int
{
$this->trap([SIGTERM, SIGINT], fn () => $this->shouldStop = true);
Order::query()->where('synced', false)->lazyById(500)->each(function (Order $order) {
if ($this->shouldStop) {
return false; // вийти після поточного запису
}
$this->sync($order);
$order->update(['synced' => true]);
});
return self::SUCCESS;
}
2. Пам'ятати прогрес у даних, а не в пам'яті: позначка synced, останній оброблений id у таблиці чи кеші. Перезапуск продовжить з місця зупинки.
3. Ідемпотентність кроку: повторна обробка того самого запису не має шкодити - перевірка стану чи унікальний ключ.
4. Один екземпляр: Isolatable і --isolated або withoutOverlapping() у планувальнику.
5. Обходити дані порціями (lazyById, chunkById) - щоб пам'ять не росла, а зміни не зсували сторінки.
Альтернатива для дуже великих обсягів - команда лише розбиває роботу на завдання черги (батчі), а повтори й паралельність бере на себе черга.
Команду запускають cron, CI, скрипти деплою - не люди. Їй треба повідомляти результат так, щоб це зрозуміла машина.
Коди виходу: 0 - успіх, будь-що інше - збій. Скрипт деплою й CI зупиняються саме на ненульовому коді.
if ($failed > 0) {
$this->error("{$failed} записів не оброблено");
return self::FAILURE;
}
return self::SUCCESS;
// або з будь-якого місця
$this->fail('Немає підключення до API');
Команда, що ловить винятки й завжди повертає 0, приховує збої від усього навколо.
Інше, що варто мати:
- неінтерактивність: без
confirm()іask()у шляху виконання за розкладом; підтвердження руйнівних дій - через--forceі перевіркуisProduction(); - журнал, а не лише консоль:
$this->info()бачить лише той, хто дивиться в термінал; підсумок і помилки - ще й уLog; --dry-runдля команд, що змінюють дані, - показати, що буде змінено;- прогрес і підсумок (оброблено, пропущено, помилки) - щоб було видно, що команда не зависла;
- тести:
$this->artisan('reports:send', ['user' => 7])->assertSuccessful()->expectsOutputToContain('Надіслано').
Через Http::pool() або Http::batch() - запити відправляються одночасно, загальний час близький до найдовшого, а не до суми.
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('rates')->timeout(3)->get('https://api.example.com/rates'),
$pool->as('news')->timeout(3)->get('https://api.example.com/news'),
$pool->as('weather')->timeout(3)->get('https://api.example.com/weather'),
]);
$rates = $responses['rates']->json();
batch() додає колбеки:
Http::batch(fn (Batch $batch) => [
$batch->get('https://api.example.com/a'),
$batch->get('https://api.example.com/b'),
])->then(fn (Batch $batch, array $results) => /* усі успішні */)
->catch(fn (Batch $batch, $key, $response) => /* один з помилкою */)
->send(); // або ->defer() - виконати після відповіді користувачу
Що враховувати:
- кожен запит у пулі налаштовують окремо - спільні заголовки не успадковуються від зовнішнього виклику;
- аргумент
concurrencyобмежує одночасні запити, щоб не впертися в ліміт стороннього API; - результат з помилкою - це
Responseз 5xx або виняток з'єднання в масиві, перевіряють кожен; poolне замінює чергу: якщо результат не потрібен відповіді, краще поставити завдання.
Для паралельного виконання довільного PHP-коду, а не HTTP, - Concurrency::run().
Виклики Http::... розкидані по контролерах - це однакові заголовки, таймаути й обробка помилок у двадцяти місцях і жодної можливості підмінити сервіс у тестах окремо від HTTP.
1. Базова конфігурація в одному місці - макрос:
// AppServiceProvider::boot()
Http::macro('github', fn () => Http::baseUrl('https://api.github.com')
->withToken(config('services.github.token'))
->acceptJson()
->timeout(5)
->retry(2, 200, throw: false));
2. Клієнт-клас з операціями предметної області:
final class GitHubClient
{
public function stars(string $repo): int
{
return Http::github()->get("/repos/{$repo}")->throw()->json('stargazers_count');
}
}
Код застосунку викликає stars('laravel/framework') і не знає про URL, заголовки й формат відповіді.
3. Свої DTO замість сирих масивів - формат API змінився, і правка в одному місці.
4. Наскрізні речі - глобальні middleware клієнта (Http::globalRequestMiddleware) для User-Agent чи кореляційного ID і подія ConnectionFailed для журналу.
5. Тести на двох рівнях: клієнт - через Http::fake() з реальними прикладами відповідей; решта коду - через підміну самого GitHubClient (інтерфейс і фейкова реалізація в контейнері).
Для великих API з десятками ендпойнтів беруть пакет Saloon - він формалізує саме цю структуру.
Проблема: секретів багато, вони змінюються, і їх треба передати новому розробнику, стейджингу й продакшену, не пересилаючи .env у месенджері.
Варіант 1 - зашифрований файл у репозиторії:
php artisan env:encrypt --env=production # створює .env.production.encrypted
php artisan env:decrypt --env=production --key=... # на сервері чи в CI
Зашифрований файл комітять разом з кодом - історія змін і рев'ю як для коду. Ключ зберігають окремо (секрет CI, LARAVEL_ENV_ENCRYPTION_KEY). Опція --readable шифрує лише значення, лишаючи назви змінних видимими в диффах.
Варіант 2 - секрет-менеджер платформи (AWS Secrets Manager, Vault, Doppler, секрети GitHub Actions, змінні Laravel Cloud чи Forge): значення потрапляють у змінні оточення процесу, файлу немає взагалі.
Принципи незалежно від інструмента:
- різні секрети для кожного оточення - ключ стейджингу не відкриває продакшен;
- мінімальні права: CI має лише те, що потрібно для деплою;
- ротація: після звільнення людини чи витоку - змінити, а не лише видалити доступ;
- секрети не в журналах, не у виводі
config:show, не в стектрейсах (APP_DEBUG=false); APP_KEYпри ротації - черезAPP_PREVIOUS_KEYS, щоб не розлогінити всіх.
Що з цього обрати - залежить від інфраструктури: команді з одним сервером вистачить зашифрованого файлу, для багатьох сервісів зручніший централізований менеджер.
Сценарій: зовнішній API ліг, і кожен запит, що до нього звертається, кидає той самий виняток. За хвилину - десятки тисяч однакових звітів: лог росте на гігабайти, Sentry вичерпує квоту за годину, а справжні нові помилки тонуть у шумі.
throttle() у bootstrap/app.php - вибіркове звітування:
use Illuminate\Support\Lottery;
use Illuminate\Cache\RateLimiting\Limit;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->throttle(function (Throwable $e) {
// кожен 1000-й звіт про часті помилки
if ($e instanceof ApiMonitoringException) {
return Lottery::odds(1, 1000);
}
// не більше 300 звітів на хвилину для збоїв зовнішніх сервісів
if ($e instanceof BroadcastException) {
return Limit::perMinute(300);
}
// ліміт окремо для кожного типу винятку
return Limit::perMinute(300)->by($e::class);
});
})
Два режими:
Lottery |
Limit |
|
|---|---|---|
| що робить | звітує випадкову частку | звітує не більше N за період |
| стан | не потрібен | лічильник у кеші (потрібен спільний драйвер, наприклад Redis) |
| для чого | дуже часті, неважливі за кількістю | сплески, при яких важливо бачити кожен перший випадок |
by() визначає, що вважається «тим самим»: за класом винятку, за повідомленням, за ідентифікатором тенанта. Без by() усі винятки ділять один спільний ліміт.
Чого throttle не замінює:
- circuit breaker - якщо сервіс лежить, не варто взагалі звертатися до нього тисячі разів: це і зайве навантаження, і повільні запити користувачів;
- агрегацію в Sentry чи Flare: вони групують однакові помилки, але кожна подія все одно рахується в квоту - тому обмеження на боці застосунку економить гроші;
- сповіщення: про те, що помилка стала частою, мають сповіщати метрики й моніторинг, а не кількість записів у лозі.
Ще кілька захистів від шуму:
dontReportDuplicates()- один виняток, переданий уreport()кілька разів, звітується раз;- рівні:
level(ConnectException::class, LogLevel::WARNING)- збій зовнішнього сервісу не повинен виглядати якerrorзастосунку; - ротація логів (канал
dailyзdays) і збір логів у централізоване сховище замість файлу на диску.
Перевірка: зробити штучний сплеск у staging і подивитися, скільки записів реально пішло в лог і в Sentry.
Докладніше в документації: Помилки: обмеження частоти звітів
Файл storage/logs/laravel.log зручний локально, але на продакшені з кількома серверами чи контейнерами логи мають збиратися централізовано (Loki, ELK, CloudWatch, Datadog), і читати їх буде не людина в tail, а система пошуку.
1. Канал у stderr для контейнерів:
// config/logging.php
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'info'),
'handler' => StreamHandler::class,
'handler_with' => ['stream' => 'php://stderr'],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
LOG_CHANNEL=stderr
LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter
Docker збирає stderr контейнера, а JSON - по одному об'єкту на рядок - збирачі розбирають без регулярних виразів: рівень, повідомлення, контекст стають полями для фільтрації.
2. Процесори Monolog - додають дані до кожного запису:
'processors' => [
PsrLogMessageProcessor::class, // підставляє {placeholders} з контексту в повідомлення
WebProcessor::class, // url, метод, ip
MemoryUsageProcessor::class,
],
3. tap - довільне налаштування логера каналу класом:
'stack' => [
'driver' => 'stack',
'tap' => [App\Logging\AddHostname::class],
'channels' => ['stderr'],
],
final class AddHostname
{
public function __invoke(Logger $logger): void
{
foreach ($logger->getHandlers() as $handler) {
$handler->pushProcessor(function (LogRecord $record): LogRecord {
return $record->with(extra: [...$record->extra, 'host' => gethostname()]);
});
}
}
}
4. Стек каналів - різні рівні в різні місця:
'stack' => ['driver' => 'stack', 'channels' => ['stderr', 'slack'], 'ignore_exceptions' => false],
'slack' => ['driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'level' => 'critical'],
Що ще важливо:
- кореляція:
Context::add('trace_id', ...)у middleware - і всі записи запиту та його завдань у черзі пов'язані одним ідентифікатором; - канал
deprecations- окремо від основного логу, щоб попередження про застарілий код не тонули й не засмічували його; - рівень на продакшені -
infoчиwarning;debugшвидко генерує гігабайти; - локальні файли без ротації на сервері рано чи пізно заповнять диск -
dailyзdaysабо зовнішній збирач; - логи - не сповіщення: про критичні помилки має повідомляти система моніторингу помилок, а лог - для розслідування.
Докладніше в документації: Логування: налаштування Monolog для каналів
Коли однакова логіка формування відповіді повторюється в багатьох контролерах, є два інструменти, щоб винести її в одне місце.
1. Інтерфейс Responsable - об'єкт сам знає, як перетворитися на відповідь:
use Illuminate\Contracts\Support\Responsable;
final class CsvExport implements Responsable
{
public function __construct(
private iterable $rows,
private string $filename,
) {}
public function toResponse($request): StreamedResponse
{
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
foreach ($this->rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $this->filename, ['Content-Type' => 'text/csv']);
}
}
public function export(): CsvExport
{
return new CsvExport(User::query()->cursor()->map->only('id', 'email'), 'users.csv');
}
Контролер повертає намір («ось експорт»), а не деталі HTTP. Так влаштовані API Resources, Uri, Inertia-відповіді.
Responsable з урахуванням формату запиту:
public function toResponse($request)
{
return $request->expectsJson()
? response()->json(['id' => $this->order->id], 201)
: to_route('orders.show', $this->order)->with('status', 'Замовлення створено');
}
Один контролер обслуговує і форму, і API.
2. Макроси відповідей - новий метод на фабриці response():
// AppServiceProvider::boot()
Response::macro('problem', function (string $title, int $status, array $extra = []) {
return Response::json(
['type' => 'about:blank', 'title' => $title, 'status' => $status, ...$extra],
$status,
['Content-Type' => 'application/problem+json'],
);
});
return response()->problem('Недостатньо коштів', 422, ['balance' => $balance]);
Що обрати:
Responsable |
Макрос | |
|---|---|---|
| де живе логіка | у власному класі | у сервіс-провайдері |
| тестування | клас тестується окремо | через HTTP-тест |
| автодоповнення в IDE | повне | потрібні PHPDoc чи ide-helper |
| для чого | складні відповіді з даними | короткі допоміжні методи формату |
Ризики:
- макроси глобальні й неявні: новий розробник не знайде, де визначено
response()->problem(), а статичний аналіз їх не бачить без додаткових анотацій; - конфлікт імен з майбутніми методами фреймворку чи пакетів;
- Responsable не повинен робити побічних дій (запис у базу, відправку листів) - він має лише формувати відповідь; інакше middleware чи тести, що створюють відповідь, викличуть побічні ефекти.
Альтернатива для помилок: власний виняток з методом render() часто кращий за макрос - його можна кинути з будь-якого рівня, а не лише повернути з контролера.
Задача: усі маршрути мають префікс {locale} чи {team}:
Route::prefix('{locale}')->group(function () {
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
});
Тепер кожен виклик route() вимагає locale: route('posts.show', ['locale' => app()->getLocale(), 'post' => $post]). Забули - виняток Missing required parameter. Посилань сотні.
URL::defaults - значення параметра за замовчуванням на весь запит:
final class SetUrlDefaults
{
public function handle(Request $request, Closure $next): Response
{
URL::defaults(['locale' => $request->route('locale') ?? config('app.locale')]);
return $next($request);
}
}
Далі просто route('posts.show', $post) - locale підставиться сам.
Пастка з прив'язкою моделей. Якщо маршрут використовує неявну прив'язку моделей ({post}), middleware SubstituteBindings будує й перевіряє параметри маршруту. Якщо ваш middleware зі значеннями за замовчуванням виконується після нього, прив'язка спрацює раніше, ніж з'являться значення, - і ви отримаєте помилки на кшталт неправильних параметрів. Тому middleware ставлять у пріоритеті перед SubstituteBindings:
->withMiddleware(function (Middleware $middleware): void {
$middleware->prependToPriorityList(
before: \Illuminate\Routing\Middleware\SubstituteBindings::class,
prepend: \App\Http\Middleware\SetUrlDefaults::class,
);
})
Де ще потрібні значення за замовчуванням:
- черги, команди, листи - там немає поточного запиту й middleware не виконується. Завдання, яке генерує посилання для листа, має встановити
URL::defaultsсаме (наприклад, з локалі отримувача) - інакше зновуMissing required parameter; - Octane: значення за замовчуванням зберігаються в генераторі URL між запитами в тому самому воркері, тож middleware має встановлювати їх на кожен запит, а не лише коли значення змінилося;
- тести: у тестах генерації посилань поза HTTP-запитом значення треба задати явно.
Альтернативи:
- субдомени (
{team}.app.com) - те саме з параметром домену вRoute::domain('{team}.example.com'); - локаль без префікса URL (з налаштувань користувача чи заголовка) - простіше для маршрутів, але гірше для SEO і кешування: одна адреса з різним вмістом.
Eloquent за замовчуванням поблажливий: багато помилок не падають, а мовчки дають неправильний результат. Model::shouldBeStrict() вмикає три перевірки одночасно:
// AppServiceProvider::boot()
Model::shouldBeStrict(! $this->app->isProduction());
1. preventLazyLoading - заборона лінивого завантаження зв'язків:
$posts = Post::all();
foreach ($posts as $post) {
$post->author->name; // LazyLoadingViolationException
}
Ловить N+1 у момент написання коду, а не після скарг на повільну сторінку. Виправлення - Post::with('author').
2. preventSilentlyDiscardingAttributes - помилка при масовому заповненні полями, яких немає в $fillable:
User::create(['name' => 'Olena', 'is_admin' => true]); // MassAssignmentException
Без суворого режиму is_admin мовчки відкидається. Звучить безпечно, але це й джерело багів: додали поле у форму, забули в $fillable - дані не зберігаються, і ніхто не розуміє чому.
3. preventAccessingMissingAttributes - помилка при зверненні до атрибута, якого немає в моделі:
$user = User::select('id', 'name')->first();
$user->email; // MissingAttributeException замість тихого null
Ловить запити з неповним select() і опечатки в назвах атрибутів.
Чому не на продакшені: виняток у лінивому завантаженні ламає сторінку для користувача там, де раніше вона просто працювала повільніше. Тому типова схема - суворо в розробці й тестах, м'яко на продакшені з логуванням:
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
if (app()->isProduction()) {
Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation]);
return;
}
throw new LazyLoadingViolationException($model, $relation);
});
Нюанси:
- одна модель - лінивий виклик зв'язку для моделі, отриманої не в колекції, не вважається порушенням (N+1 там неможливий);
Model::automaticallyEagerLoadRelationships()- альтернативний підхід: Eloquent сам довантажує зв'язок для всієї колекції при першому зверненні;- увімкнення на старому проєкті покаже десятки порушень - вмикайте поступово й лагодьте, інакше команда просто вимкне перевірку;
- тести - найкраще місце для суворого режиму: порушення в тестах ламають CI ще до рев'ю.
Традиційно модель налаштовують захищеними властивостями й методами booted(). Laravel поступово додає PHP-атрибути класу, що описують ту саму конфігурацію декларативно:
use Illuminate\Database\Eloquent\Attributes\{Table, Fillable, Hidden, ObservedBy, ScopedBy, UseFactory, CollectedBy};
#[Table('legacy_orders', key: 'order_id', timestamps: false)]
#[Fillable(['customer_id', 'total', 'status'])]
#[Hidden(['internal_note'])]
#[ObservedBy(OrderObserver::class)]
#[ScopedBy(TenantScope::class)]
#[UseFactory(OrderFactory::class)]
#[CollectedBy(OrderCollection::class)]
final class Order extends Model {}
Те саме через властивості й методи:
class Order extends Model
{
protected $table = 'legacy_orders';
protected $primaryKey = 'order_id';
public $timestamps = false;
protected $fillable = ['customer_id', 'total', 'status'];
protected $hidden = ['internal_note'];
protected static function booted(): void
{
static::observe(OrderObserver::class);
static::addGlobalScope(new TenantScope);
}
}
Що дають атрибути:
- конфігурація відокремлена від поведінки: метадані над класом, а в тілі лишаються зв'язки, касти, бізнес-методи;
- видно одразу, які спостерігачі й глобальні області видимості підключені до моделі - не треба шукати по
booted()і сервіс-провайдерах; - немає побічних ефектів у
booted()і ризику забутиparent::booted()у спадкоємцях; - статичний аналіз: IDE й інструменти читають атрибути через Reflection без запуску коду.
Що варто знати:
- атрибути читаються через Reflection при першому зверненні й кешуються - на швидкість це практично не впливає;
- успадкування: сам PHP атрибути не наслідує, але Eloquent шукає атрибут у класі моделі, її трейтах і далі в батьківських класах - тож конфігурація з базової моделі чи трейту діє в спадкоємцях, а найближчий атрибут перекриває дальші;
- змішування стилів в одному проєкті (частина моделей з властивостями, частина з атрибутами) ускладнює читання - варто домовитися про один стиль;
- не все має атрибут: касти, зв'язки, аксесори лишаються методами.
Інші атрибути Laravel у тому самому стилі: #[UsePolicy] для зв'язку моделі з політикою, #[UseResource] для API Resource, #[Scope] для позначення методу локальною областю видимості без префікса scope.
Практично: для нових моделей атрибути зручніші; переписувати старі заради стилю немає сенсу - поведінка однакова.
Багато класів Laravel використовують трейт Macroable: Str, Stringable, Arr, Collection, Request, Builder, Http, Number, Uri та інші. Макрос додає метод «ззовні» без успадкування.
// AppServiceProvider::boot()
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
Str::macro('phone', function (string $value): string {
return preg_replace('/\D+/', '', $value);
});
Stringable::macro('phone', function (): Stringable {
return new Stringable(Str::phone($this->value));
});
Str::phone('+38 (067) 123-45-67'); // '380671234567'
str('+38 (067) 123-45-67')->phone(); // Stringable
Важливі деталі:
StrіStringable- різні класи: макрос наStrне з'являється у fluent-ланцюжку. Для обох стилів потрібні два макроси;$thisу замиканні прив'язується до екземпляра (дляStringable- до об'єкта з властивістюvalue), а в статичному виклику - ні;mixinреєструє кілька макросів з класу, кожен публічний чи захищений метод якого повертає замикання:
Str::mixin(new StrMixin);
Ризики:
- Конфлікт з майбутніми методами фреймворку. Макроси викликаються через
__call/__callStatic, тобто лише якщо справжнього методу немає. Якщо в наступній версії Laravel з'явитьсяStr::phone()з іншою поведінкою, ваш макрос мовчки перестане викликатися. Захист - префікси (Str::appPhone) і тести на поведінку макросів; - Невидимість для інструментів: IDE й PHPStan не бачать макросів без додаткових анотацій чи ide-helper; автодоповнення зникає, а аналізатор лається на «невідомий метод»;
- Глобальний стан: макрос зареєстрований для всього процесу. У пакеті він може зіткнутися з макросом іншого пакета чи застосунку з тим самим ім'ям - перемагає той, хто зареєструвався останнім;
- Прихована логіка: бізнес-правила в макросах (
Str::orderNumber()) важче знайти й тестувати, ніж у звичайному класі.
Коли макрос доречний: невелика загальна утиліта, що природно продовжує API класу (форматування телефону, нормалізація пробілів) і використовується в багатьох місцях.
Коли краще звичайний клас чи value object: доменна логіка, щось із залежностями, чи метод, потрібний в одному модулі. PhoneNumber::fromString($raw)->normalized() явніший, тестується окремо й підтримується IDE без підказок.
Склеювання URL рядками - джерело тонких помилок: забутий ? чи &, подвійне кодування, втрачені параметри, // у шляху, неекранований пробіл.
// крихко
$url = $base . '/search?q=' . $query . '&page=' . $page;
Illuminate\Support\Uri (на основі бібліотеки League URI) дає незмінний (immutable) об'єкт для розбору й зміни адрес:
use Illuminate\Support\Uri;
$uri = Uri::of('https://example.com/search?q=php')
->withQuery(['page' => 2]) // злиття з наявними параметрами
->withFragment('results');
(string) $uri; // 'https://example.com/search?q=php&page=2#results'
$uri->host(); // 'example.com'
$uri->path(); // 'search'
$uri->query()->get('q'); // 'php'
$uri->pathSegments(); // колекція сегментів
Змінення параметрів запиту:
| Метод | Що робить |
|---|---|
withQuery([...]) |
додає й перезаписує параметри (merge: false - замінює всі) |
withQueryIfMissing([...]) |
лише відсутні - зручно для значень за замовчуванням |
withoutQuery(['utm_source']) |
прибрати параметри |
replaceQuery([...]) |
замінити весь рядок запиту |
pushOntoQuery('tags', 'php') |
додати значення до масиву tags[] |
Кожен with... повертає новий об'єкт - вихідний не змінюється, тож базову адресу можна безпечно перевикористовувати.
Інтеграція з маршрутизацією:
Uri::route('posts.show', ['post' => $post]);
Uri::signedRoute('unsubscribe', ['user' => $user]);
Uri::temporarySignedRoute('download', now()->addHour(), ['file' => $file]);
return Uri::to('/dashboard')->withQuery(['tab' => 'billing'])->redirect();
Поточний запит: $request->uri() повертає той самий об'єкт, тож «поточна сторінка з іншим фільтром» будується без ручного розбору $_GET:
$request->uri()->withQuery(['sort' => 'price'])->withoutQuery(['page']);
Де це особливо важливо:
- очищення URL від трекінгових параметрів перед збереженням чи порівнянням;
- побудова посилань на сторонні API з набором параметрів;
- перевірка редиректів:
Uri::of($next)->host()замість регулярних виразів - щоб не відкрити відкритий редирект на чужий домен.
Обмеження: Uri не валідує, що адреса безпечна чи досяжна. Для SSRF-захисту потрібна окрема перевірка схеми, хоста й IP-адреси, до якої він резолвиться.
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії