Питання на співбесіді: Хелпери й рядки
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
8 питань
Laravel має два способи працювати з рядками, і обидва побудовані на тому самому наборі методів.
1. Статичний клас Str - кожен метод приймає рядок і повертає новий рядок:
use Illuminate\Support\Str;
Str::limit('Довгий текст статті', 10); // 'Довгий тек...'
Str::headline('laravel_framework'); // 'Laravel Framework'
Str::mask('olena@example.com', '*', 3); // 'ole**************'
Str::contains($title, ['Laravel', 'PHP']);
Str::uuid();
2. Fluent-рядки - Str::of() чи коротший хелпер str() повертають об'єкт Stringable, і методи можна складати в ланцюжок:
$slug = str(' Нова версія Laravel ')
->squish() // прибрати зайві пробіли
->limit(50, '')
->slug('-', 'uk');
Str::of($path)->afterLast('/')->beforeLast('.');
Між Str::of('...') і str('...') різниці немає: це той самий Stringable. А виклик str() без аргументів повертає об'єкт, що перенаправляє виклики до статичних методів Str: str()->uuid().
Коли що обирати:
| Ситуація | Що зручніше |
|---|---|
| одна операція | Str::limit($text, 100) |
| кілька перетворень поспіль | str($text)->squish()->lower()->... |
| умовні кроки | str($x)->when($cond, fn ($s) => $s->append('...')) |
Stringable - не рядок. Він приводиться до рядка в echo, Blade і конкатенації, але === з рядком дасть false, а функція з типом string у строгому режимі отримає об'єкт. Тому в кінці ланцюжка часто викликають ->toString() чи ->value():
$name = str($input)->trim()->title()->toString();
Кирилиця: методи Str враховують багатобайтові символи - str('Привіт')->length() повертає 6, а не 12, як strlen(). Але Str::slug() і Str::ascii() транслітерують за правилами мови, яку треба вказати явно, якщо результат має бути українською транслітерацією.
Перед тим як писати власну функцію для рядків варто переглянути список методів Str: там уже є between, excerpt, initials, wordCount, plural, squish, isUuid, isJson і десятки інших.
Клас Illuminate\Support\Number - обгортка над розширенням intl для форматування чисел з урахуванням локалі. Для більшості методів розширення intl обов'язкове.
use Illuminate\Support\Number;
Number::format(1234567.891, precision: 2, locale: 'uk'); // '1 234 567,89'
Number::currency(1500, in: 'UAH', locale: 'uk'); // '1 500,00 ₴'
Number::percentage(12.345, precision: 1); // '12.3%'
Number::fileSize(1536000, precision: 1); // '1.5 MB'
Number::abbreviate(1500000, precision: 1); // '1.5M'
Number::forHumans(1500000); // '2 million'
Number::spell(42, locale: 'uk'); // 'сорок два'
Number::ordinal(3); // '3rd'
Number::clamp($value, min: 1, max: 100);
Що варто знати:
precisionза замовчуванням 0 уfileSize,abbreviateіforHumans:Number::forHumans(1500000)округлює до'2 million'. Для дробової частини вказуйтеprecision;fileSizeрахує в степенях 1024, а не 1000;forHumansіabbreviateне локалізовані: вони завжди пишуть англійською (million,M);format,currency,percentage,spellприймаютьlocale, а в українській локалі роздільник тисяч - нерозривний пробіл, десятковий - кома.
Локаль для всього застосунку чи фрагмента:
// у сервіс-провайдері
Number::useLocale('uk');
Number::useCurrency('UAH');
// тимчасово для одного блоку
Number::withLocale('de', fn () => Number::format(1234.5)); // '1.234,5'
Зворотна операція - розібрати введене користувачем число з урахуванням локалі:
Number::parseFloat('1 234,5', locale: 'uk');
Гроші: Number::currency() - лише для відображення. Рахувати гроші через float не можна: зберігайте суму в копійках цілим числом (або в decimal у базі) і форматуйте тільки при виводі:
Number::currency($order->total_cents / 100, in: 'UAH');
У Blade це зручно загорнути в accessor моделі чи Blade-компонент, щоб формат валюти й точність були однаковими на всьому сайті.
Вкладені масиви (конфігурація, JSON зі стороннього API, payload вебхука) зручно читати крапковою нотацією замість ланцюжка isset:
use Illuminate\Support\Arr;
$data = [
'user' => ['profile' => ['name' => 'Olena']],
'orders' => [['total' => 10], ['total' => 20]],
];
Arr::get($data, 'user.profile.name'); // 'Olena'
Arr::get($data, 'user.email', 'n/a'); // 'n/a' - значення за замовчуванням
Arr::has($data, 'user.profile.name'); // true
data_get - те саме, але для масивів і об'єктів разом і з підтримкою *:
data_get($data, 'orders.*.total'); // [10, 20]
data_get($request->json(), 'payment.status');
data_get($model, 'author.profile.city'); // працює й з Eloquent-моделями
Запис:
Arr::set($config, 'mail.from.address', 'hello@example.com'); // за посиланням
data_set($data, 'orders.*.paid', false); // усім елементам
data_fill($data, 'meta.source', 'api'); // лише якщо ключа ще немає
Arr::forget($data, 'user.password');
Інші корисні методи:
| Метод | Що робить |
|---|---|
Arr::only($a, ['name', 'email']) |
лише вказані ключі |
Arr::except($a, ['password']) |
усі, крім вказаних |
Arr::wrap($value) |
гарантує масив: null → [], 'a' → ['a'] |
Arr::dot($a) / Arr::undot($a) |
вкладений масив ↔ плаский з ключами a.b.c |
Arr::pluck($a, 'user.name') |
значення поля з кожного елемента |
Arr::first($a, fn ($v) => ...) |
перший елемент за умовою |
Arr::string($a, 'key'), Arr::integer(...) |
значення з перевіркою типу, інакше виняток |
Пастка крапкової нотації: якщо в ключі масиву справді є крапка ('version.major' => 1), Arr::get($a, 'version.major') спершу перевіряє точний ключ і знайде його, але data_get і запис через Arr::set трактуватимуть крапку як вкладеність. Для таких даних краще звичайний доступ $a['version.major'].
Arr::wrap особливо корисний для параметрів, що приймають «одне значення або масив»: foreach (Arr::wrap($recipients) as $email) - без перевірок типу.
Для колекцій є ті самі можливості: collect($data)->get(...), ->pluck('user.name'), ->dot(). Якщо дані обробляються ланцюжком перетворень - колекція зручніша, для одного звернення - Arr чи data_get.
Ці функції закривають типові шаблони коду, які інакше пишуть вручну з 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() корисний і в продакшен-коді: повертає результат разом з тривалістю, яку можна записати в лог чи метрику для повільних операцій.
Багато класів 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-адреси, до якої він резолвиться.