string в PHP — это байты, а не «текст вообще»

В PHP строка — это последовательность байтов. У неё нет встроенной метки «это UTF-8», «это Windows-1251» или «это бинарный файл». Один и тот же тип string хранит имя пользователя, JSON, HTML-фрагмент, содержимое CSV, бинарные данные из файла и тело HTTP-запроса. Поэтому строковый код в PHP почти всегда должен отвечать на два вопроса: «я работаю с байтами или с символами?» и «в какой кодировке эти байты надо понимать?»

<?php

$text = 'Привет';

echo strlen($text) . PHP_EOL;              // 12 байтов в UTF-8
echo mb_strlen($text, 'UTF-8') . PHP_EOL;  // 6 символов

strlen() не «ошибается»: она делает ровно то, что обещает, — считает байты. Для сетевых протоколов, бинарных файлов, хэшей и лимитов в байтах это правильная функция. Для пользовательского текста на русском, казахском, японском или с эмодзи чаще нужны функции mb_*.

flowchart LR A[Байты в string] --> B{Какой контекст?} B --> C[Бинарные данные<br/>strlen/substr допустимы] B --> D[Пользовательский текст UTF-8<br/>mb_strlen/mb_substr] B --> E[HTML-вывод<br/>htmlspecialchars] B --> F[JSON<br/>json_encode/json_decode] D --> G[Явная кодировка: UTF-8] E --> G F --> G
flowchart LR
    A[Байты в string] --> B{Какой контекст?}
    B --> C[Бинарные данные<br/>strlen/substr допустимы]
    B --> D[Пользовательский текст UTF-8<br/>mb_strlen/mb_substr]
    B --> E[HTML-вывод<br/>htmlspecialchars]
    B --> F[JSON<br/>json_encode/json_decode]
    D --> G[Явная кодировка: UTF-8]
    E --> G
    F --> G
Одна и та же PHP-строка может быть байтовым буфером, пользовательским текстом или данными для конкретного формата. Выбор функции зависит от контекста.

Литералы, кавычки и interpolation

Строковые литералы в PHP можно записывать несколькими способами. Одинарные кавычки почти ничего не интерпретируют: переменные не подставляются, \n остаётся двумя символами — обратным слэшем и n.

<?php

$name = 'Анна';

echo 'Привет, $name\n';
// Привет, $name\n

Двойные кавычки интерпретируют escape-последовательности и подставляют переменные.

<?php

$name = 'Анна';
$count = 3;

echo "Привет, $name\n";
echo "Новых сообщений: {$count}\n";

Фигурные скобки в interpolation лучше использовать, когда выражение стоит рядом с буквами или индексами массива. Это убирает неоднозначность и хорошо читается рядом со структурами из Массивы как ordered map.

<?php

$user = ['name' => 'Анна'];

echo "Профиль: {$user['name']}";

Для больших многострочных строк есть heredoc и nowdoc. Heredoc ведёт себя как двойные кавычки: переменные подставляются. Nowdoc ведёт себя как одинарные кавычки: содержимое почти буквально.

<?php

$title = 'Отчёт';

$html = <<<HTML
<h1>{$title}</h1>
<p>Строка с подстановкой.</p>
HTML;

$template = <<<'TPL'
<h1>{$title}</h1>
<p>Строка без подстановки.</p>
TPL;

Практическое правило: heredoc удобен для читаемых шаблонных фрагментов внутри PHP-кода, nowdoc — для примеров кода, SQL-шаблонов, регулярных выражений и текста, где $ не должен внезапно стать переменной. Но HTML для пользователя всё равно надо экранировать; см. XSS, экранирование вывода и шаблоны.

Байтовые функции и UTF-8

Многие стандартные строковые функции исторически работают с байтами: strlen(), substr(), strpos(), strtolower(), strtoupper() и часть функций семейства str_*. На ASCII-тексте это незаметно, потому что один символ обычно занимает один байт. На UTF-8 кириллице один символ обычно занимает два байта, а некоторые символы — больше.

<?php

$text = 'Книга';

echo substr($text, 0, 2) . PHP_EOL;         // может вывести только «К»
echo mb_substr($text, 0, 2, 'UTF-8') . PHP_EOL; // «Кн»

Если разрезать UTF-8 строку посередине многобайтного символа, получится битая строка. Она может странно отображаться, не пройти JSON-кодирование или испортить данные при записи в файл. Это особенно часто всплывает в коротких превью: «обрезать заголовок до 120 символов» через substr() — баг для русского интерфейса.

Для пользовательского текста обычно выбирают такие пары:

<?php

mb_strlen($text, 'UTF-8');
mb_substr($text, 0, 120, 'UTF-8');
mb_strtolower($text, 'UTF-8');
mb_strtoupper($text, 'UTF-8');
mb_strpos($text, 'искать', 0, 'UTF-8');

Есть тонкость: mb_strlen() считает символы в выбранной кодировке, но не всегда совпадает с тем, что человек считает «одним видимым знаком». Например, буква с комбинируемым диакритическим знаком или некоторые эмодзи могут состоять из нескольких code points. Для таких случаев в PHP есть функции grapheme_* из расширения intl, но в обычном CRUD-коде mb_* уже закрывает главный класс ошибок с кириллицей.

mbstring: не магия, а явный контракт

Расширение mbstring даёт функции для многобайтных кодировок и конвертации между кодировками. В современном веб-приложении разумный дефолт — UTF-8 на всех границах: исходники, HTML, JSON, база, файлы импорта, HTTP-заголовки.

<?php

mb_internal_encoding('UTF-8');

header('Content-Type: text/html; charset=UTF-8');

mb_internal_encoding() задаёт внутреннюю кодировку по умолчанию для функций mbstring, но это не превращает любые входящие байты в UTF-8. Если файл пришёл из старой бухгалтерской системы в Windows-1251, его надо конвертировать явно.

<?php

$raw = file_get_contents('clients.csv');
$utf8 = mb_convert_encoding($raw, 'UTF-8', 'Windows-1251');

Не стоит слепо полагаться на autodetect кодировки: короткие строки и похожие байтовые диапазоны легко дают неправильный результат. Если источник известен, передавайте исходную кодировку явно. Работа с файлами и file_get_contents() подробнее разбирается в Файловая система и stream wrappers.

Строки на границах системы

Самые неприятные строковые баги возникают не внутри маленькой функции, а на границах: HTTP-ввод, база, файл, JSON, HTML-вывод.

Входные данные надо валидировать как данные, а не «экранировать на входе». Например, имя пользователя можно проверить на длину через mb_strlen(), нормализовать пробелы, запретить управляющие символы. Это связано с GET, POST и фильтрация ввода.

<?php

$name = trim($_POST['name'] ?? '');

if ($name === '' || mb_strlen($name, 'UTF-8') > 80) {
    throw new InvalidArgumentException('Некорректное имя');
}

Вывод в HTML — отдельная операция. Для HTML-текста обычно используют htmlspecialchars() с явной кодировкой и флагами по умолчанию современных PHP-версий.

<?php

echo htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

Для JSON не собирайте строку руками через конкатенацию: используйте json_encode() и проверяйте ошибки или включайте исключения. Это уже тема JSON, сериализация и форматы данных. Для HTTP-заголовков важно помнить, что заголовок Content-Type и фактические байты ответа должны совпадать; см. HTTP-заголовки, ответы и редиректы.

Частые ловушки

Первая ловушка — считать strlen() длиной текста. Это длина в байтах. Для лимита «не больше 80 символов» используйте mb_strlen() с явной кодировкой.

Вторая — обрезать UTF-8 через substr(). Для превью, заголовков и имён используйте mb_substr().

Третья — думать, что mb_internal_encoding('UTF-8') чинит входные файлы. Она задаёт дефолт для mbstring, но не угадывает происхождение байтов.

Четвёртая — смешивать в проекте UTF-8, Windows-1251 и «как получилось». Лучше один раз договориться: всё внутреннее хранение — UTF-8, конвертация только на входе из легаси-источников и на выходе в чужие системы.

Пятая — путать экранирование и кодировку. UTF-8 отвечает на вопрос «как символы представлены байтами». Экранирование отвечает на вопрос «как безопасно вставить строку в HTML, SQL, JSON, URL или shell». Для SQL это ведёт к Prepared statements и SQL injection, для HTML — к XSS, экранирование вывода и шаблоны.

См. также

Sources

  1. PHP Manual: Strings
  2. PHP Manual: String Functions
  3. PHP Manual: strlen
  4. PHP Manual: Multibyte String
  5. PHP Manual: mb_internal_encoding
  6. PHP Manual: mb_convert_encoding
  7. PHP Manual: htmlspecialchars