Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Через Http::fake() - запити не йдуть у мережу, а отримують задані відповіді.
it('imports exchange rates', function () {
Http::preventStrayRequests();
Http::fake([
'api.rates.example/*' => Http::response(['usd' => 41.2, 'eur' => 44.9]),
]);
app(RatesImporter::class)->import();
expect(Rate::where('code', 'usd')->value('value'))->toBe(41.2);
Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization'));
});
Що підміняють:
- конкретні адреси шаблонами з
*; - послідовності:
Http::sequence()->push(...)->pushStatus(500)- перша відповідь успішна, друга з помилкою; - збій з'єднання:
Http::failedConnection(); - логіку: замикання, що повертає відповідь залежно від запиту.
Перевірки запитів: assertSent, assertNotSent, assertSentCount, assertNothingSent.
preventStrayRequests() - будь-який непідмінений запит кидає виняток. Його ставлять у базовому TestCase чи Pest.php: тест, що випадково ходить у справжній API, стає повільним, нестабільним і може щось змінити в чужій системі.
Що обов'язково покрити: не лише успіх, а й 500, таймаут, 429, неочікуваний формат відповіді - саме ці гілки ламаються в продакшені.
У Laravel 11+ обробник винятків налаштовується не в класі Handler, а в bootstrap/app.php через withExceptions:
use Illuminate\Foundation\Configuration\Exceptions;
use Psr\Log\LogLevel;
->withExceptions(function (Exceptions $exceptions): void {
// власне звітування для конкретного типу
$exceptions->report(function (InvalidOrderException $e) {
Notification::route('slack', config('services.slack.ops'))->notify(new OrderAlert($e));
});
// не звітувати зовсім
$exceptions->dontReport([
CardDeclinedException::class,
]);
// інший рівень логування для типу
$exceptions->level(PDOException::class, LogLevel::CRITICAL);
// додатковий контекст до кожного звіту
$exceptions->context(fn () => [
'tenant_id' => tenant()?->id,
]);
})
Важлива деталь report(): колбек виконується на додачу до стандартного логування. Щоб стандартне звітування не відбувалося, колбек має повернути false або ланцюжок має закінчуватися ->stop():
$exceptions->report(function (InvalidOrderException $e) {
// ...
})->stop();
Тип винятку визначається з type hint параметра колбека - окремо вказувати клас не потрібно.
Інші способи не звітувати:
- інтерфейс
ShouldntReportна класі винятку - позначка прямо в класі; dontReportWhen(fn (Throwable $e) => ...)- умова за вмістом винятку;dontReportDuplicates()- той самий екземпляр винятку, переданий уreport()кілька разів, звітується лише раз.
Контекст на рівні винятку: метод context() у самому класі винятку додає його дані до запису в логу:
class InvalidOrderException extends Exception
{
public function __construct(private int $orderId) { parent::__construct('Invalid order'); }
public function context(): array
{
return ['order_id' => $this->orderId];
}
}
Laravel 13: dontRetry - список винятків, при яких завдання в черзі не повторюється, навіть якщо спроби ще лишилися. Наприклад, видалений клієнт у стороннього API: повтор нічого не змінить.
Практика: тримати в dontReport лише очікувані бізнес-ситуації. Помилка, яку ніхто не бачить, - це баг, про який ніхто не знає.
Замість реєструвати колбеки в bootstrap/app.php можна описати поведінку прямо в класі винятку - методами report() і render(). Обробник Laravel викличе їх сам.
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
final class InsufficientBalanceException extends Exception
{
public function __construct(
public readonly int $required,
public readonly int $available,
) {
parent::__construct('Недостатньо коштів на балансі.');
}
public function report(): bool
{
// очікувана бізнес-ситуація, звітувати не потрібно
return true;
}
public function render(Request $request): Response|JsonResponse
{
if ($request->expectsJson()) {
return response()->json([
'message' => $this->getMessage(),
'required' => $this->required,
'available' => $this->available,
], 422);
}
return back()->withErrors(['balance' => $this->getMessage()]);
}
}
Як працюють значення, що повертаються:
| Метод | Повертає | Результат |
|---|---|---|
report() |
false |
стандартне логування виконується |
report() |
true чи нічого (void) |
виняток вважається обробленим, стандартного логування немає |
render() |
відповідь | її отримає користувач |
render() |
false |
стандартний рендеринг Laravel |
Навіщо власні винятки:
- доменна мова:
InsufficientBalanceExceptionу коді й логах зрозуміліше, ніжRuntimeException('balance'); - дані в полях: скільки потрібно, скільки є - а не розбір тексту повідомлення;
- точкові
catch: викликаючий код ловить саме цей випадок, а несподівані помилки летять далі; - одне місце для того, як ця ситуація виглядає для користувача.
Коли краще колбеки в bootstrap/app.php: для чужих винятків (з пакетів чи фреймворку), до класу яких немає доступу, і для спільної політики - наприклад, усі помилки API в одному форматі.
Порада щодо ієрархії: базовий виняток модуля (BillingException) і конкретні нащадки - тоді можна зловити все, що стосується модуля, одним catch, а звітування налаштувати для базового класу.
Докладніше в документації: Помилки: винятки з власним рендерингом
Context - сховище даних про поточний запит, завдання чи команду, яке Laravel автоматично додає до кожного запису в лог і передає в завдання черги, що були поставлені в цьому запиті.
use Illuminate\Support\Facades\Context;
// middleware
public function handle(Request $request, Closure $next): Response
{
Context::add('url', $request->url());
Context::add('trace_id', (string) Str::uuid());
return $next($request);
}
Тепер будь-який запис у лог під час цього запиту містить ці дані:
Log::info('Користувача автентифіковано', ['auth_id' => Auth::id()]);
local.INFO: Користувача автентифіковано {"auth_id":27} {"url":"https://example.com/login","trace_id":"e04e1a11-..."}
Передача в черги - головна перевага. Завдання, поставлене в черзу під час запиту, отримує той самий контекст, і його логи містять той самий trace_id. Ланцюжок «запит → завдання → вкладене завдання» можна відстежити одним пошуком у логах.
Основні методи:
Context::add('key', 'value'); // перезаписати
Context::addIf('key', 'value'); // лише якщо ще немає
Context::push('breadcrumbs', 'first'); // стек значень
Context::get('key');
Context::has('key');
Context::forget('key');
Context::scope(function () {
// тимчасовий контекст лише для цього блоку
}, ['import_id' => $import->id]);
Прихований контекст - передається в черги, але не пишеться в лог:
Context::addHidden('api_token', $token);
Context::getHidden('api_token');
Події dehydrating і hydrated дозволяють вирішувати, що відбувається при передачі в чергу: наприклад, зберегти локаль запиту й відновити її в завданні.
Чим це відрізняється від Log::withContext():
Log::withContext |
Context |
|
|---|---|---|
| потрапляє в лог | так | так (крім прихованого) |
| передається в черги | ні | так |
| можна прочитати в коді | ні | так (Context::get) |
Що не класти в Context: великі об'єкти й моделі - контекст серіалізується з кожним завданням і пишеться в кожен рядок логу. Ідентифікатори, а не сутності.
Не кожна помилка має зупиняти запит. Якщо не вдалося оновити рекомендації чи відправити аналітику, користувач усе одно має отримати сторінку - але розробник має про це дізнатися.
report() - передати виняток обробнику для звітування (лог, Sentry) і продовжити виконання:
public function isValid(string $value): bool
{
try {
// перевірка, що може кинути виняток
} catch (Throwable $e) {
report($e);
return false;
}
}
Без report() виняток у catch просто зникає - це «проковтування» помилки, яке роками ховає баги.
rescue() - виконати замикання, а при винятку звітувати й повернути значення за замовчуванням:
$recommendations = rescue(
fn () => $this->recommender->for($user),
[], // значення при помилці
);
// без звітування - третій аргумент
$preview = rescue(fn () => $this->renderPreview($post), null, report: false);
Значенням за замовчуванням може бути й замикання - воно виконається лише при помилці.
report_if() / report_unless():
report_if($response->failed(), new PaymentGatewayException($response->body()));
Дублікати: якщо той самий виняток передали в report() кілька разів (у сервісі й ще раз у контролері), у лозі з'являться дублікати. $exceptions->dontReportDuplicates() у bootstrap/app.php звітує кожен екземпляр лише раз.
Коли rescue - погана ідея:
- помилки, від яких залежить коректність: оплата, збереження замовлення, перевірка прав. Тиха заміна на значення за замовчуванням тут перетворює помилку на неправильні дані;
- широкий блок коду:
rescue()навколо половини методу ховає і очікувані, і зовсім несподівані збої. Загортайте лише конкретну необов'язкову операцію; - без звітування (
report: false) - лише коли помилка справді очікувана і не цікава.
Правило: якщо помилку ловите, то або обробляєте осмислено, або звітуєте. Порожній catch - майже завжди баг.
Додати cookie до відповіді:
return response('Hello')->cookie('theme', 'dark', minutes: 60 * 24 * 365);
return response('Hello')->withoutCookie('theme'); // видалити
Черга cookie - коли відповіді ще немає (сервіс, middleware, слухач події):
use Illuminate\Support\Facades\Cookie;
Cookie::queue('last_seen_post', $post->id, minutes: 60);
Cookie::expire('promo_banner');
Laravel прикріпить cookie з черги до відповіді, яка буде відправлена.
Читання:
$theme = $request->cookie('theme');
Шифрування за замовчуванням. Middleware EncryptCookies шифрує й підписує всі cookie, які створює Laravel, ключем APP_KEY. Наслідки:
- клієнт не може прочитати чи підробити значення - змінене cookie просто не розшифрується, і
$request->cookie()повернеnull; - JavaScript бачить зашифрований рядок, а не значення;
- cookie, встановлене не Laravel (з JavaScript чи іншим сервісом), Laravel спробує розшифрувати й отримає
null.
Виключення з шифрування - для cookie, які має читати фронтенд чи інший сервіс:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: [
'theme',
'consent',
]);
})
Незашифровані cookie не можна вважати надійними: користувач змінює їх як завгодно, тож у них - лише налаштування інтерфейсу, ніколи не права чи ідентифікатори.
Атрибути безпеки (за замовчуванням з config/session.php): secure, httpOnly, sameSite. Для власних cookie їх можна передати в cookie():
cookie('theme', 'dark', 525600, path: '/', domain: null, secure: true, httpOnly: false, sameSite: 'lax');
Що пам'ятати:
- зміна
APP_KEYробить усі зашифровані cookie, включно з сесією, недійсними - користувачів розлогінить (допомагаєAPP_PREVIOUS_KEYS); - розмір: браузер обмежує cookie приблизно 4 КБ, а шифрування збільшує значення - великі дані в cookie не кладуть;
- кешування сторінок: відповідь з
Set-CookieCDN зазвичай не кешує, тож cookie на кожній сторінці ламає кеш на краю мережі.
Звичайна відповідь формується повністю, а потім відправляється. Потокова - відправляється частинами в міру готовності: користувач бачить перші дані раніше, а сервер не тримає всю відповідь у пам'яті.
stream - довільні дані частинами:
return response()->stream(function (): void {
foreach ($this->generator->chunks() as $chunk) {
echo $chunk;
ob_flush();
flush();
}
}, 200, ['X-Accel-Buffering' => 'no']);
Простіше - передати замикання-генератор: Laravel сам відправлятиме кожне yield і скидатиме буфер:
Route::get('/answer', function (LlmClient $llm) {
return response()->stream(function () use ($llm) {
foreach ($llm->stream(request('prompt')) as $token) {
yield $token;
}
});
});
streamJson - великий JSON, де частина даних - лінива колекція:
return response()->streamJson([
'users' => User::query()->cursor(),
]);
Рядки читаються з бази й кодуються в JSON по одному - пам'ять не залежить від кількості записів.
eventStream - Server-Sent Events (text/event-stream):
return response()->eventStream(function () {
while ($progress = $this->import->progress()) {
yield new StreamedEvent(event: 'progress', data: ['percent' => $progress]);
sleep(1);
}
});
Браузер слухає через EventSource, а наприкінці Laravel надсилає завершальне повідомлення </stream>. Для React і Vue є готові хуки useStream і useEventStream з пакетів @laravel/stream-react / @laravel/stream-vue.
Що ламає потокову передачу:
- буферизація на шляху: Nginx за замовчуванням буферизує відповідь від PHP-FPM - заголовок
X-Accel-Buffering: noчи налаштуванняproxy_buffering off; так само CDN і стиснення gzip, що чекає на заповнення буфера; - тайм-аути:
max_execution_time, тайм-аути проксі й балансувальника обривають довге з'єднання; - воркер зайнятий: кожен відкритий потік тримає процес PHP-FPM. Сотня користувачів, що слухають SSE, - сотня зайнятих воркерів. Для масових підписок на події краще WebSocket через Reverb;
- сесія: блокування сесії (
->block()) чи сесія, відкрита на весь час потоку, затримує інші запити того самого користувача; - помилка посеред потоку: статус 200 уже відправлено - про помилку доведеться повідомити в самих даних.
Коли доречно: відповіді LLM по токенах, експорт великих даних, прогрес довгої операції.
Основні хелпери:
url('/posts/42'); // абсолютний URL з APP_URL чи поточного хоста
route('posts.show', $post); // за іменем маршруту - рекомендований спосіб
route('posts.show', ['post' => $post, 'tab' => 'comments']); // зайві параметри стають query
route('posts.index', absolute: false); // відносний шлях /posts
action([PostController::class, 'show'], $post);
secure_url('/checkout'); // примусово https
Чому route(), а не рядки: змінили URL у файлі маршрутів - усі посилання оновилися. Помилка в імені маршруту - виняток одразу, а не тихе посилання в нікуди.
Модель як параметр: Laravel підставляє ключ маршруту моделі (getRouteKey()), тож якщо маршрут використовує {post:slug} чи модель перевизначає getRouteKeyName(), у URL буде slug.
Поточна й попередня адреса:
url()->current(); // без query
url()->full(); // з query
url()->previous(); // з заголовка Referer чи сесії
url()->previousPath();
request()->routeIs('posts.*'); // для активних пунктів меню
Змінити параметри поточної адреси (наприклад, сторінку чи сортування) зручніше не склеюванням рядків, а через $request->fullUrlWithQuery(['page' => 3]) чи об'єкт Uri.
Типові проблеми:
- неправильний домен чи
httpзамістьhttpsза балансувальником - не налаштовані довірені проксі, і Laravel не знає, що запит прийшов по HTTPS; - URL у листах і завданнях черги будуються з
APP_URL, бо поточного запиту немає. НеправильнийAPP_URL- посилання наlocalhostу листах; url()->previous()для логіки безпеки - заголовокRefererконтролює клієнт;- підписані URL (
URL::signedRoute) - для посилань, які не можна підробити: відписка, підтвердження пошти.
Усі три способи записують дані, але працюють на різних рівнях.
updateOrCreate - модель за моделлю:
Price::updateOrCreate(
['product_id' => 42, 'currency' => 'UAH'],
['amount' => 1999],
);
Два запити (пошук і INSERT чи UPDATE), повний життєвий цикл моделі: події saving, created/updated, спостерігачі, мутатори й касти. Для тисячі рядків - тисячі пар запитів.
upsert - один запит на всю партію:
Price::upsert(
[
['product_id' => 42, 'currency' => 'UAH', 'amount' => 1999],
['product_id' => 43, 'currency' => 'UAH', 'amount' => 2499],
],
uniqueBy: ['product_id', 'currency'],
update: ['amount'],
);
Перетворюється на INSERT ... ON CONFLICT (...) DO UPDATE у PostgreSQL чи INSERT ... ON DUPLICATE KEY UPDATE у MySQL. Eloquent додає created_at і updated_at.
insert - масове вставлення без перевірки існування; при дублікаті - помилка унікальності. insertOrIgnore - пропускає конфлікти мовчки.
Порівняння:
updateOrCreate |
upsert |
insert |
|
|---|---|---|---|
| запитів на N рядків | ~2N | 1 | 1 |
| події моделі й спостерігачі | так | ні | ні |
| касти й мутатори | так | ні (сирі значення) | ні |
| timestamps | так | так | ні |
| атомарність між процесами | ні (гонка між SELECT і INSERT) | так | так |
Що важливо знати про upsert:
- колонки
uniqueByмають бути покриті первинним ключем чи унікальним індексом - на ньому база визначає конфлікт. Без індексу в PostgreSQL запит падає; - MySQL ігнорує
uniqueByі використовує будь-який унікальний індекс таблиці - результат може здивувати, якщо їх кілька; - події не спрацьовують: спостерігачі, що оновлюють кеш чи пошуковий індекс, не дізнаються про зміни - це треба зробити окремо;
- касти не застосовуються: JSON-колонку треба передати вже закодованим рядком, enum - значенням.
Гонка в updateOrCreate: два паралельні запити можуть обидва не знайти запис і обидва спробувати вставити - один отримає помилку унікальності. upsert такої проблеми не має, бо конфлікт розв'язує сама база.
Коли що: одиничне збереження з бізнес-логікою в подіях - updateOrCreate; імпорт, синхронізація цін, лічильники - upsert.
Багато таблиць ростуть безкінечно: журнали подій, прочитані сповіщення, прострочені токени, м'яко видалені записи. Laravel дає вбудований механізм їх періодично прибирати.
Prunable - описати, які записи застаріли:
use Illuminate\Database\Eloquent\Prunable;
class LoginAttempt extends Model
{
use Prunable;
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->subMonth());
}
protected function pruning(): void
{
// перед видаленням кожної моделі: прибрати пов'язані файли тощо
}
}
Запуск за розкладом:
// routes/console.php
Schedule::command('model:prune')->daily();
Команда знаходить усі моделі з трейтами Prunable / MassPrunable у app/Models і видаляє записи, які повертає prunable().
php artisan model:prune --pretend # скільки буде видалено, без видалення
php artisan model:prune --model="App\Models\LoginAttempt"
Prunable проти MassPrunable:
Prunable |
MassPrunable |
|
|---|---|---|
| як видаляє | завантажує моделі порціями й викликає delete() для кожної |
один DELETE за запитом (порціями) |
події deleting/deleted, спостерігачі |
так | ні |
хук pruning() |
так | ні |
| швидкість на мільйонах рядків | повільно | швидко |
М'яке видалення: якщо модель використовує SoftDeletes, Prunable видаляє записи остаточно (forceDelete). Типова схема - м'яко видалені записи старші за 30 днів прибирати назавжди.
Що важливо:
- індекс на колонці умови (
created_at) - інакше щоденне очищення читає всю таблицю; - перший запуск на таблиці з роками даних може видалити мільйони рядків і довго тримати блокування - краще спершу почистити вручну порціями, а потім ввімкнути розклад;
MassPrunableне викликає подій - пов'язані файли, кеш, пошуковий індекс не очищаються автоматично;- політика зберігання даних (скільки зберігати журнали, персональні дані) - це юридичне питання, а
prunable()- місце, де вона виконується в коді; - тестування: у тесті можна створити старі й нові записи фабрикою, викликати
$this->artisan('model:prune')і перевірити, що лишилися лише нові.
Події життєвого циклу моделі:
| Операція | Події по порядку |
|---|---|
створення (save нової моделі) |
saving → creating → INSERT → created → saved |
оновлення (save існуючої) |
saving → updating → UPDATE → updated → saved |
| видалення | deleting → DELETE → deleted (з SoftDeletes ще trashed) |
відновлення (restore) |
restoring → restored (з проміжними saving/updating) |
| остаточне видалення | forceDeleting → forceDeleted |
| завантаження з бази | retrieved |
replicate() |
replicating |
Події «до» (-ing) можуть скасувати операцію: якщо слухач поверне false, запис не відбудеться, а save() поверне false.
saving проти creating/updating: saving - для логіки, однакової при створенні й оновленні (нормалізація, генерація slug); creating - лише для нових записів (встановити автора).
Оновлення без змін не спричиняє updating/updated: якщо жоден атрибут не змінився (isDirty() порожній), запит не виконується. Але saving і saved спрацьовують.
Що НЕ запускає події:
Post::where('status', 'draft')->update(['status' => 'archived']); // масове оновлення
Post::where('created_at', '<', $date)->delete(); // масове видалення
Post::insert([...]);
Post::upsert([...], ...);
Запит іде напряму в базу, моделі не завантажуються, тож спостерігачі не дізнаються про зміни. Це часте джерело багів: «кеш оновлюється при редагуванні, але не при масовій архівації».
Зберегти без подій:
$post->saveQuietly();
$post->updateQuietly(['views' => $post->views + 1]);
$post->deleteQuietly();
Post::withoutEvents(function () {
// усі операції всередині - без подій
});
Корисно для технічних оновлень (лічильники переглядів), міграцій даних і сидерів.
Події й транзакції: подія created спрацьовує до коміту транзакції. Якщо слухач ставить завдання в чергу, воркер може почати його раніше, ніж дані стануть видимими, або транзакція відкотиться після відправленого листа. Рішення - спостерігач з інтерфейсом ShouldHandleEventsAfterCommit чи afterCommit для завдань.
Порада: логіку, без якої дані некоректні, краще тримати явно в сервісі чи дії, а події - для побічних ефектів (кеш, індекс, аудит). Інакше поведінка залежить від того, яким методом збережено модель.
Ці функції закривають типові шаблони коду, які інакше пишуть вручну з try, if і тимчасовими змінними.
retry - повторити операцію, що може тимчасово впасти:
$rate = retry(3, fn () => $client->fetchRate('USD'), 200);
// своя затримка для кожної спроби
retry([100, 500, 2000], fn () => $client->fetchRate('USD'));
// повторювати лише для певних помилок
retry(3, $callback, 200, fn (Throwable $e) => $e instanceof ConnectionException);
Затримка - у мілісекундах, може бути замиканням від номера спроби. Після останньої невдачі виняток кидається далі. Повторювати варто лише ідемпотентні операції: повтор POST на оплату може списати гроші двічі.
rescue - виконати й повернути запасне значення замість винятку:
$weather = rescue(fn () => $api->current($city), null);
$weather = rescue(fn () => $api->current($city), fn () => Cache::get("weather:$city"), report: false);
За замовчуванням виняток звітується (потрапляє в лог і Sentry), але не перериває запит. report: false - для очікуваних збоїв.
optional - безпечний доступ до властивостей null:
optional($user->address)->city;
У PHP 8 зазвичай краще nullsafe-оператор $user->address?->city. optional лишається корисним із замиканням: optional($user, fn ($u) => $u->fullName()).
tap - виконати дію над значенням і повернути саме значення:
return tap($user)->update(['last_login_at' => now()]); // повертає $user, а не true
value - повертає значення, а якщо це замикання - результат його виклику. Зручно для параметрів «значення або callback».
once - мемоізація в межах одного об'єкта чи запиту:
public function stats(): array
{
return once(fn () => $this->calculateExpensiveStats());
}
Повторні виклики stats() на тому самому екземплярі повернуть збережений результат. Для статичного контексту - один раз на запит.
Також: blank() / filled() (порожній рядок, пробіли, null, порожня колекція), transform() (застосувати callback, якщо значення не порожнє), when().
Міра: хелпер має робити код зрозумілішим. Ланцюжок tap(optional(value(...))) гірший за три рядки звичайного коду.
Str::slug() перетворює текст на фрагмент URL: транслітерує, переводить у нижній регістр і замінює все, крім літер і цифр, на роздільник.
Str::slug('Laravel 13 Framework'); // 'laravel-13-framework'
Сигнатура: Str::slug($title, $separator = '-', $language = 'en', $dictionary = ['@' => 'at']).
Третій параметр - мова транслітерації, і за замовчуванням це 'en'. Для кирилиці це дає результат за загальними правилами, а не за українськими:
Str::slug('Київ - столиця України'); // 'kiyiv-stolicia-ukrayini'
Str::slug('Київ - столиця України', '-', 'uk'); // 'kyyiv-stolytsia-ukrayiny'
Str::slug('Щастя й ґанок'); // 'shhastia-i-ganok'
Str::slug('Щастя й ґанок', '-', 'uk'); // 'shchastia-y-ganok'
З 'uk' використовується українська таблиця (щ → shch, и → y, ц → ts), близька до офіційної транслітерації.
Unicode-slug без транслітерації:
Str::slug('Привіт', '-', null); // 'привіт'
Браузери показують такі адреси нормально, але при копіюванні вони перетворюються на %D0%BF%D1%80... - для посилань у месенджерах і логах це незручно.
Практичні правила:
- обрати одну мову транслітерації й використовувати її скрізь - у моделі, у міграції даних, у тестах. Якщо частина slug-ів створена з
'en', а частина з'uk', той самий заголовок дасть різні адреси; - змінювати правила транслітерації на живому сайті - це зміна URL усіх сторінок. Потрібні 301-редиректи зі старих адрес, інакше втрачаються пошукові позиції й зовнішні посилання;
- slug не має змінюватися автоматично при редагуванні заголовка опублікованого матеріалу - з тієї самої причини;
- унікальність:
Str::slugне перевіряє базу. Дублікати треба обробляти окремо (суфікс-2, ідентифікатор у URL чи унікальний індекс у базі); $dictionaryзамінює символи до транслітерації:['@' => 'at', '&' => 'and'].
Str::transliterate() і Str::ascii() - те саме перетворення без заміни пробілів і нижнього регістру; ascii теж приймає мову другим параметром.
Проблема sleep(): код з паузами (очікування між повторами, обмеження частоти запитів до API) робить тести повільними, а перевірити, скільки саме чекав код, неможливо.
Клас Sleep - обгортка над sleep/usleep, яку можна підробити в тестах:
use Illuminate\Support\Sleep;
Sleep::for(500)->milliseconds();
Sleep::for(2)->seconds();
Sleep::until(now()->addMinute());
// між запитами до API з лімітом частоти
foreach ($pages as $page) {
$client->fetch($page);
Sleep::for(1)->second();
}
У тесті:
use Illuminate\Support\Sleep;
Sleep::fake();
$importer->run();
Sleep::assertSlept(fn (Carbon\CarbonInterval $d) => $d->totalSeconds === 1, times: 3);
Sleep::assertSequence([
Sleep::for(1)->second(),
Sleep::for(2)->seconds(),
]);
Sleep::assertNeverSlept();
Тест проходить миттєво, а затримки перевіряються явно. Sleep::fake(syncWithCarbon: true) ще й пересуває «поточний час» Carbon на тривалість паузи.
Бонус: хелпер retry() під капотом чекає через Sleep, тож Sleep::fake() робить миттєвими й тести з повторними спробами.
Benchmark - швидко порівняти варіанти коду:
use Illuminate\Support\Benchmark;
Benchmark::dd([
'eager' => fn () => Post::with('author')->get(),
'lazy' => fn () => Post::all()->each->author,
], iterations: 10);
// ["eager" => "12.3ms", "lazy" => "48.7ms"]
$ms = Benchmark::measure(fn () => $report->build()); // середній час у мс
[$result, $ms] = Benchmark::value(fn () => $report->build()); // результат і час
Обмеження Benchmark:
- це мікробенчмарк у поточному процесі: кеш бази, OPcache, прогрів впливають на результат. Перша ітерація часто повільніша - тому
iterations; - для продакшен-продуктивності потрібні профілювальники й моніторинг (Xdebug, Blackfire, Pulse, APM), а не
Benchmark::dd; Benchmark::dd()зупиняє виконання - лише для локального налагодження.
Benchmark::value() корисний і в продакшен-коді: повертає результат разом з тривалістю, яку можна записати в лог чи метрику для повільних операцій.
Пакет має працювати одразу після встановлення з розумними значеннями за замовчуванням, але дозволяти змінити їх застосунку.
mergeConfigFrom (у register) - злиття конфігурації пакета з конфігурацією застосунку:
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/invoices.php', 'invoices');
}
Тепер config('invoices.currency') працює, навіть якщо застосунок нічого не публікував.
publishes (у boot) - дозволяє скопіювати файл у застосунок для редагування:
public function boot(): void
{
$this->publishes([
__DIR__.'/../config/invoices.php' => config_path('invoices.php'),
], 'invoices-config');
}
php artisan vendor:publish --tag=invoices-config
Як працює злиття - і головна пастка:
// всередині mergeConfigFrom
array_merge(require $packageConfig, config('invoices', []));
- значення застосунку перемагають значення пакета;
- злиття неглибоке - лише перший рівень ключів. Якщо пакет має
'pdf' => ['size' => 'A4', 'orientation' => 'portrait'], а застосунок у своєму файлі вказав'pdf' => ['size' => 'Letter'], ключorientationзникне повністю; - для рекурсивного злиття є
replaceConfigRecursivelyFrom(), але воно має свої наслідки для масивів-списків.
Звідси правило проєктування конфігурації пакета: тримати її пласкою, а вкладені групи - мінімальними, і документувати, що опублікований файл замінює групу цілком.
config:cache: коли конфігурацію закешовано, mergeConfigFrom нічого не робить - значення вже в кеші. Тому змінюючи конфігурацію пакета в розробці, не забувайте config:clear.
Теги публікації:
- конвенція -
<пакет>-config,<пакет>-migrations,<пакет>-views; vendor:publish --provider="Acme\Invoices\InvoicesServiceProvider"публікує все від провайдера;- міграції краще публікувати через
publishesMigrations()- Laravel оновить мітку часу в імені файлу, щоб міграція виконалася після наявних; - не змушуйте публікувати конфігурацію для базової роботи: якщо без
vendor:publishпакет падає - це погана інтеграція.
Значення з оточення: у конфігурації пакета можна використовувати env('INVOICES_CURRENCY', 'UAH'), а в коді пакета - лише config(), бо після config:cache виклики env() поза конфігураційними файлами повертають null.
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії