php

Регулярные выражения в PHP: preg_match, preg_replace и preg_split на практике

Регулярные выражения в PHP — рабочая лошадка для валидации форм, парсинга логов, очистки HTML и любой задачи, где нужно найти структуру внутри строки. В этом руководстве разберём весь набор PCRE-функций (preg_match, preg_match_all, preg_replace, preg_replace_callback, preg_split), синтаксис шаблонов и подводные камни, на которых обычно горят разработчики.

Анатомия регулярного выражения в PHP: разделители, якоря, классы символов, квантификаторы, флаги
Структура простого regex: разделители, якоря, классы, квантификаторы и флаги

Анатомия шаблона: разделители, якоря и флаги

Любой PCRE-шаблон в PHP — это строка, обёрнутая в пару одинаковых символов-разделителей. Чаще всего используют слэш /, но если внутри шаблона много слэшей (например, при работе с URL), удобнее взять # или ~ — меньше экранирования.

// Эквивалентные шаблоны — проверяем https-ссылку
preg_match('/^https:\/\/[\w.-]+/', $url);
preg_match('#^https://[\w.-]+#', $url);
preg_match('~^https://[\w.-]+~', $url);

После закрывающего разделителя ставятся модификаторы:

  • i — игнорировать регистр (case-insensitive)
  • u — режим UTF-8, обязателен при работе с кириллицей
  • s — точка . совпадает в том числе с переводом строки
  • m — многострочный режим, ^ и $ работают на каждой строке
  • x — расширенный режим, можно делать пробелы и комментарии в шаблоне

Якоря ^ и $ привязывают шаблон к началу и концу строки — без них совпадение может найтись где угодно в середине. Это самая частая ошибка валидации: шаблон /[a-z]+/ найдёт буквы в любой строке, включая 123abc!@#. Правильно — /^[a-z]+$/.

preg_match: проверка и захват групп

Функция preg_match возвращает 1 при первом совпадении, 0 если ничего не найдено и false при ошибке шаблона. Третий аргумент по ссылке — массив с захваченными группами.

// Валидация телефона в формате +7 (XXX) XXX-XX-XX
$phone = '+7 (495) 123-45-67';
$pattern = '/^\+7\s\((\d{3})\)\s(\d{3})-(\d{2})-(\d{2})$/';

if (preg_match($pattern, $phone, $matches)) {
    // $matches[0] — вся подстрока целиком
    // $matches[1..4] — захваченные группы
    [$full, $code, $a, $b, $c] = $matches;
    echo "Код города: {$code}, номер: {$a}-{$b}-{$c}";
}

Группы можно делать именованными — это намного читаемее, чем нумерованные индексы. Синтаксис: (?P<имя>...).

// Парсим строку лога Nginx: IP, дата, метод, URL, код ответа
$line = '192.168.1.1 - - [07/May/2026:10:23:45 +0300] "GET /api/users HTTP/1.1" 200';
$pattern = '/^(?P<ip>\d+\.\d+\.\d+\.\d+).*\[(?P<date>[^\]]+)\]\s"(?P<method>\w+)\s(?P<url>[^\s]+).*"\s(?P<code>\d{3})/';

if (preg_match($pattern, $line, $m)) {
    echo "IP: {$m['ip']}, метод: {$m['method']}, URL: {$m['url']}, код: {$m['code']}";
}

Совет: всегда оборачивайте вызов в проверку === 1 или !== false, если важно отличить «не нашёл» от «ошибка в шаблоне». Сравнение через if (preg_match(...)) подходит только когда ошибки шаблона исключены — например, шаблон-константа.

preg_match_all: все совпадения сразу

Если в строке несколько вхождений и нужны все — preg_match не подойдёт, он находит только первое. Используем preg_match_all: возвращает количество найденных совпадений, в массив пишет все группы.

// Достаём все email-адреса из текста
$text = 'Пишите на support@example.com или sales@example.org, а лучше info@karoche.ru';
$pattern = '/[\w.+-]+@[\w-]+\.[\w.-]+/u';

preg_match_all($pattern, $text, $matches);
print_r($matches[0]);
// [0 => 'support@example.com', 1 => 'sales@example.org', 2 => 'info@karoche.ru']

Четвёртый аргумент управляет порядком группировки. По умолчанию (PREG_PATTERN_ORDER) $matches[0] — массив всех полных совпадений, $matches[1] — все первые группы и т.д. С флагом PREG_SET_ORDER структура «переворачивается»: каждое совпадение становится отдельным массивом — это удобнее, когда нужно итерировать по результатам.

// Извлекаем все теги img и их src+alt
$html = '<img src="a.jpg" alt="Кот"><img src="b.png" alt="Пёс">';
$pattern = '/<img\s+src="([^"]+)"\s+alt="([^"]+)"/u';

preg_match_all($pattern, $html, $matches, PREG_SET_ORDER);

foreach ($matches as $m) {
    echo "Файл: {$m[1]}, подпись: {$m[2]}\n";
}
// Файл: a.jpg, подпись: Кот
// Файл: b.png, подпись: Пёс

Важно: для разбора реального HTML-документа лучше использовать DOMDocument или simplexml_load_string — regex плохо справляется с вложенностью и ломаной разметкой. Шаблон выше работает только на строго предсказуемых строках.

preg_replace и preg_replace_callback

Простая замена по шаблону — это preg_replace. В замене можно использовать обратные ссылки $1, $2 на группы из шаблона.

// Превращаем markdown-ссылки [текст](url) в HTML
$md = 'Подробнее в [статье](https://karoche.ru/regex)';
$html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $md);
// <a href="https://karoche.ru/regex">статье</a>

// Чистим лишние пробелы — несколько подряд → один
$clean = preg_replace('/\s+/u', ' ', '  это    строка  с   пробелами  ');
// 'это строка с пробелами'

Когда замена требует логики (преобразование значения, обращение к БД, расчёт) — нужен preg_replace_callback. Вторым аргументом передаётся функция, в которую попадает массив совпадений; то, что она вернёт, заменит совпадение.

// Подсветка цен: каждое число с "руб" оборачиваем в span и форматируем
$text = 'Цена 12000 руб, скидка 1500 руб';

$result = preg_replace_callback(
    '/(\d+)\s*руб/u',
    function ($m) {
        $price = (int) $m[1];
        $formatted = number_format($price, 0, '', ' '); // '12 000'
        return "<span class=\"price\">{$formatted} ₽</span>";
    },
    $text
);
// 'Цена <span class="price">12 000 ₽</span>, скидка <span class="price">1 500 ₽</span>'

Колбэк удобен для генерации slug, замены тегов BB-кода на HTML, маскирования персональных данных в логах — всего, где сама замена не сводится к шаблону вида $1.

preg_split: разбиение строк

Аналог explode, но с регулярным выражением в роли разделителя. Незаменим, когда разделители разные или нужно отбросить пустые элементы.

// Разбиваем по запятым, точкам с запятой и пробелам
$tags = 'php, javascript;  css regex,bitrix';
$parts = preg_split('/[\s,;]+/u', $tags, -1, PREG_SPLIT_NO_EMPTY);
// ['php', 'javascript', 'css', 'regex', 'bitrix']

// Парсим CSV-строку с экранированием — простой случай
$csv = 'Иван,"Петров, ст.",42,Москва';
$cells = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $csv);
// ['Иван', '"Петров, ст."', '42', 'Москва']

Флаг PREG_SPLIT_NO_EMPTY убирает пустые элементы (когда в строке два разделителя подряд). PREG_SPLIT_DELIM_CAPTURE сохраняет совпадения групп в результате — полезно для токенизации.

Подводные камни и производительность

Regex — мощный инструмент, но у него есть несколько ловушек, в которые попадают почти все:

  1. Кириллица без флага u. Шаблон /[а-я]+/ без u-флага работает на байтах, не на символах, и даёт неверные результаты в UTF-8. Всегда добавляйте u: /[а-яё]+/iu.
  2. Жадные квантификаторы. Шаблон /<.*>/ для строки <b>текст</b> схватит всё от первого < до последнего > — то есть всю строку. Решение — ленивый квантификатор: /<.*?>/ или класс с инверсией: /<[^>]+>/.
  3. Catastrophic backtracking. Шаблоны вида /(a+)+b/ на длинной строке без b в конце могут уронить процесс или упереться в лимит pcre.backtrack_limit. Избегайте вложенных квантификаторов; используйте атомарные группы (?>...) или posessive-квантификаторы ++, *+.
  4. Неэкранированные спецсимволы в литералах. Если подставляете пользовательский ввод в шаблон — оборачивайте через preg_quote: preg_quote($search, '/').
  5. Экранирование в строке-замене. В preg_replace символ $ в строке-замене интерпретируется как обратная ссылка. Если нужен буквальный знак доллара — пишите \\$.

Простой пример безопасной подстановки пользовательского значения:

// Подсвечиваем введённое слово в тексте — безопасно
function highlight(string $text, string $needle): string {
    $escaped = preg_quote($needle, '/');
    return preg_replace(
        '/(' . $escaped . ')/iu',
        '<mark>$1</mark>',
        $text
    );
}

echo highlight('PHP — это просто.', 'php');
// 'php' и 'PHP' будут обёрнуты в <mark>

По производительности: PCRE компилирует шаблон при каждом вызове, но в PHP внутренний кэш переиспользует уже скомпилированные шаблоны (см. pcre.recursion_limit и связанные настройки). Если в горячем цикле один и тот же шаблон применяется тысячи раз — производительность будет нормальной без дополнительных трюков. Однако для совсем простых задач (поиск подстроки, разбиение по одному символу) strpos и explode работают в разы быстрее regex — не используйте PCRE там, где хватит обычных строковых функций.


Итог: чеклист по работе с PCRE

  • Используйте u-флаг для всего, что касается UTF-8 (а это почти всегда).
  • Якоря ^ и $ для валидации — без них шаблон ловит подстроку в любой части ввода.
  • Именованные группы (?P<name>...) читаемее нумерованных.
  • Для нескольких вхождений — preg_match_all с флагом PREG_SET_ORDER.
  • Сложная замена с логикой — preg_replace_callback.
  • Любой пользовательский ввод в шаблоне — через preg_quote.
  • Жадные квантификаторы делайте ленивыми (.*?) или сужайте через инверсию ([^>]+).
  • Не парсите HTML и JSON через regex — для этого есть DOMDocument, json_decode, simplexml_load_string.
  • Тестируйте шаблоны на regex101.com с включённым PCRE2-режимом — там сразу видны группы, флаги и описание совпадений.

Регулярные выражения за пару часов разбора превращаются из загадочных закорючек в инструмент, который экономит десятки строк ручного парсинга. Главное — не пытаться запихнуть в один шаблон всё подряд: лучше два простых regex, чем один монструозный, который никто (включая автора через неделю) уже не прочитает.