<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
CORE PHP · JUNIOR ЧАСТО ПИТАЮТЬ

Чому strlen рахує кирилицю неправильно і коли потрібні mb_-функції?

Рядок у PHP - це послідовність байтів без інформації про кодування, тому `strlen` рахує байти, а кирилиця в UTF-8 займає по два байти на літеру. Символи рахують і ріжуть `mb_`-функції (`mb_strlen`, `mb_substr`, `mb_strtolower`), а регулярні вирази переводить у режим символів модифікатор `u`.

Скільки поверне `strlen('Привіт')` і чому не 6?
Чому в кінці анонсу на сторінці зʼявився чорний ромб зі знаком питання, хоча в базі текст цілий?
Чому `strtolower('ЖИТОМИР')` нічого не змінив?
Чим `substr` відрізняється від `mb_substr`, якщо на англійському тексті вони дають однаковий результат?
strings mbstring UTF-8 encoding preg PHP 8.4

Рядок у 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;
Що UTF-8 має змінну ширину: ASCII займає 1 байт, кирилиця 2, більшість емодзі 4, а `strlen` чесно повертає саме кількість байтів.
Що `substr` і `str_pad` ріжуть і доповнюють по байтах, тому обрізаний анонс закінчується половиною літери, а колонки в таблиці не вирівнюються.
Що `strtolower`, `strtoupper` і `ucfirst` з PHP 8.2 гарантовано працюють лише з ASCII і кирилицю не чіпають узагалі.
Що в PHP 8.3 зʼявився `mb_str_pad`, а в 8.4 - `mb_trim`, `mb_ltrim`, `mb_rtrim`, `mb_ucfirst`, `mb_lcfirst`, і до цього їх доводилося писати руками.
Що модифікатор `u` вмикає в PCRE режим UTF-8 разом із Unicode-класами, тому `\w` і `\b` починають бачити кирилицю, а на битому вході `preg_match` повертає `false`.
Казати «треба поставити правильне кодування в php.ini»: `mbstring.internal_encoding` давно видалений, а рядок у PHP не зберігає кодування взагалі.
Валідувати довжину пароля чи опису через `strlen`: користувач із кирилицею отримає вдвічі жорсткіше обмеження, ніж написано у формі.
Різати анонс через `substr($text, 0, 100)` і потім лікувати симптом через `iconv` з `//IGNORE` замість того, щоб узяти `mb_substr`.
Вважати, що `mb_strtolower` враховує локаль: турецьке `I` він переведе в `i`, а не в `ı`, бо правила не залежать від мови.
Писати регулярку з кирилицею без `/u` і дивуватися, чому `/^[а-яіїєґ]+$/` пропускає сміття: без модифікатора діапазон рахується по байтах.
Ставити `mb_` абсолютно скрізь, зокрема на ASCII-константах і в гарячих циклах: це повільніше без жодної користі.
ПОРАДА

Одна фраза, що закриває питання: «рядок у PHP - це масив байтів без кодування, тому `strlen` рахує байти, і будь-яка операція, де важлива літера, а не байт, потребує `mb_`». Далі назвіть свіже: `mb_str_pad` з 8.3, `mb_trim` і `mb_ucfirst` з 8.4, і модифікатор `u` для regex. Це показує, що ви стежите за релізами, а не переказуєте статтю десятирічної давнини.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Рядок у PHP - це послідовність байтів без збереженої інформації про кодування, і `strlen` повертає саме їхню кількість. Кирилиця в UTF-8 кодується двома байтами на літеру, тому 6 символів дають 12 байтів. Ніяка настройка php.ini цього не змінює: кількість символів рахує `mb_strlen`.