Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Хелпери й рядки

Питання з реальних співбесід з відповідями: 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 і десятки інших.

Докладніше в документації: Рядки: fluent-рядки

Клас 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.

Докладніше в документації: Хелпери: 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(...))) гірший за три рядки звичайного коду.

Докладніше в документації: Хелпери: retry

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 теж приймає мову другим параметром.

Докладніше в документації: Рядки: Str::slug

Проблема 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() корисний і в продакшен-коді: повертає результат разом з тривалістю, яку можна записати в лог чи метрику для повільних операцій.

Докладніше в документації: Хелпери: Sleep

Багато класів 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);

Ризики:

  1. Конфлікт з майбутніми методами фреймворку. Макроси викликаються через __call / __callStatic, тобто лише якщо справжнього методу немає. Якщо в наступній версії Laravel з'явиться Str::phone() з іншою поведінкою, ваш макрос мовчки перестане викликатися. Захист - префікси (Str::appPhone) і тести на поведінку макросів;
  2. Невидимість для інструментів: IDE й PHPStan не бачать макросів без додаткових анотацій чи ide-helper; автодоповнення зникає, а аналізатор лається на «невідомий метод»;
  3. Глобальний стан: макрос зареєстрований для всього процесу. У пакеті він може зіткнутися з макросом іншого пакета чи застосунку з тим самим ім'ям - перемагає той, хто зареєструвався останнім;
  4. Прихована логіка: бізнес-правила в макросах (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-адреси, до якої він резолвиться.

Докладніше в документації: Хелпери: URI