Рядок у PHP не зберігає свого кодування. Це просто послідовність байтів, і strlen чесно повідомляє, скільки їх там лежить. UTF-8 кодує символи змінною кількістю байтів: латиниця й цифри вкладаються в один байт, уся кирилиця з блоку U+0400-U+04FF (разом з і, ї, є, ґ) займає два, типографські лапки й довге тире по три, більшість емодзі по чотири. Звідси strlen('Привіт') дорівнює 12, а не 6. Помилки тут немає: функція рахує байти, бо про символи вона нічого не знає й знати не може.
Ламається все там, де байт видають за літеру. substr($city, 0, 5) на слові «Львів» відріже два повні символи й перший байт третього, і далі ця половинка літери піде в шаблон. Браузер покаже U+FFFD (той самий ромб зі знаком питання), json_encode поверне false з JSON_ERROR_UTF8, а в базі текст при цьому цілий, тож причину починають шукати не там. str_pad доповнює рядок до заданої кількості байтів, тому вирівняна по колонках таблиця з українськими назвами перетворюється на драбинку. Валідація strlen($password) >= 8 мовчки пропустить пароль із чотирьох кириличних літер, хоча в підказці під полем стоїть вісім. З регістром окрема історія: починаючи з PHP 8.2 strtolower і strtoupper не залежать від локалі й працюють тільки з діапазоном A-Z, тому strtolower('ЖИТОМИР') повертає незмінений рядок. Раніше результат залежав від setlocale, тобто від конфігурації сервера, і це було гірше, ніж передбачувана відмова.
Робочий набір заміни невеликий: mb_strlen, mb_substr, mb_strtolower, mb_strtoupper, mb_strpos, mb_str_split (з PHP 7.4). Довгий час у цього набору були дірки, які кожен затикав своїм хелпером. PHP 8.3 додав mb_str_pad, PHP 8.4 закрив ще дві: mb_trim, mb_ltrim, mb_rtrim знімають Unicode-пробіли, зокрема нерозривний U+00A0, який регулярно приїжджає з Word і який звичайний trim не бачить; mb_ucfirst і mb_lcfirst нарешті роблять із «одеси» «Одесу», чого ucfirst не вміє в принципі. Парних mb_-функцій усе ще немає для strrev, wordwrap і str_word_count, і для перших двох звичайний обхідний шлях такий: розібрати рядок через mb_str_split і зібрати назад. Вказувати кодування останнім аргументом зазвичай не потрібно, бо внутрішнє кодування mbstring за замовчуванням UTF-8.
Байти протікають назовні ще в одному місці, у регулярних виразах. Без модифікатора u двигун PCRE працює з байтами: . збігається з одним байтом, {3} рахує байти, а діапазон [а-яіїєґ] розкладається в набір байтів і починає пропускати комбінації, яких у ньому не мало бути. Модифікатор u вмикає в PHP одразу два прапорці PCRE2, UTF і UCP, тому символьні класи стають Unicode-класами: \w і \b починають розуміти кирилицю, \p{L} і \p{Lu} стають доступні, а квантифікатори рахують символи. За це доводиться платити: у режимі u двигун перевіряє вхід, і на невалідному UTF-8 preg_match повертає false, а не 0. Різниця критична, бо if (!preg_match(...)) однаково спрацює на «не збіглося» і на «дані биті». Перевіряйте preg_last_error() === PREG_BAD_UTF8_ERROR, коли працюєте з тим, що прийшло ззовні.
Ставити mb_ скрізь підряд теж не варто. Ці функції повільніші за байтові й на ASCII-константах не дають нічого, тому міняти strlen($uuid) на mb_strlen($uuid) у циклі на мільйон ітерацій сенсу немає. Байти лишаються правильною одиницею там, де вони і є предметом: розмір завантаженого файла, Content-Length, ліміт довжини ключа індексу в InnoDB, хешування, робота з бінарними даними. У зворотний бік межа теж є: mb_strlen рахує code points, а не те, що людина сприймає як окремий знак, тож емодзі з модифікатором чи літера з окремою комбінованою діакритикою дадуть більше одиниці. Коли рахувати треба саме видимі знаки, потрібне розширення intl з grapheme_strlen і grapheme_substr.
declare(strict_types=1);
$city = 'Львів'; // 5 символів, 9 байтів у UTF-8
var_dump(strlen($city), mb_strlen($city)); // int(9), int(5)
echo substr($city, 0, 5); // «Ль» + половина «в»: зламаний байт
echo mb_substr($city, 0, 4); // «Льві»
// strtolower з PHP 8.2 гарантовано ASCII-only і кирилицю не чіпає
echo strtolower('ЖИТОМИР'); // ЖИТОМИР
echo mb_strtolower('ЖИТОМИР'); // житомир
// str_pad доповнює до потрібної кількості байтів, тому колонка «їде»
echo str_pad($city, 10, '.'), "|"; // 9 байтів + 1 крапка
echo mb_str_pad($city, 10, '.'), "|"; // PHP 8.3+: рівно 5 крапок
// PHP 8.4: mb_trim знімає й нерозривний пробіл, mb_ucfirst знає кирилицю
echo mb_trim("\u{00A0}одеса\u{00A0}"); // «одеса», trim() тут безсилий
echo mb_ucfirst('одеса'); // «Одеса», ucfirst() поверне «одеса»
// без /u регулярка працює з байтами, з /u - із символами й Unicode-класами
var_dump(preg_match('/^\w+$/', 'Тарас')); // int(0)
var_dump(preg_match('/^\w+$/u', 'Тарас')); // int(1)
// на битому UTF-8 preg_* не кидає винятку, а тихо повертає false
$broken = substr($city, 0, 5);
var_dump(preg_match('/./u', $broken)); // bool(false)
var_dump(preg_last_error() === PREG_BAD_UTF8_ERROR); // bool(true)
// анонс для списку: рахуємо й ріжемо по символах
$title = 'Розробник PHP у київський продуктовий стартап';
echo mb_strlen($title) > 30
? mb_substr($title, 0, 30) . '…'
: $title;
Одна фраза, що закриває питання: «рядок у PHP - це масив байтів без кодування, тому `strlen` рахує байти, і будь-яка операція, де важлива літера, а не байт, потребує `mb_`». Далі назвіть свіже: `mb_str_pad` з 8.3, `mb_trim` і `mb_ucfirst` з 8.4, і модифікатор `u` для regex. Це показує, що ви стежите за релізами, а не переказуєте статтю десятирічної давнини.