Regular Expressions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5, which bundles PCRE2 10.44, checked on 2026-10-05. PHP’s regular expressions are Perl-compatible (PCRE): the preg_* functions take a pattern string, compile it once per request (compiled patterns are cached), and can use the PCRE JIT. The older POSIX ereg* functions were removed in PHP 7.0.

Pattern syntax

Delimiters and modifiers

A PHP pattern is a string containing the expression between delimiters, followed by optional modifiers: '/^978-\d+$/i'. The delimiter can be any non-alphanumeric, non-backslash, non-whitespace character; /, # and ~ are common, and bracket pairs ((…), {…}, […], <…>) also work. Pick a delimiter that does not appear in the expression to avoid escaping it: '~^/books/(\d+)$~' reads better than '/^\/books\/(\d+)$/'.

Modifier Effect

i

case-insensitive

m

multiline: ^ and $ match at line breaks, not only at the start and end of the subject

s

dotall: . also matches newlines

x

extended: whitespace in the pattern is ignored and # starts a comment, for readable patterns

u

UTF-8: pattern and subject are treated as UTF-8 code points, and invalid UTF-8 makes the match fail

n

no auto-capture (8.2): plain (…) groups do not capture; only named groups do

D

$ matches only at the very end (not before a final newline)

U

ungreedy: inverts the greediness of quantifiers (prefer ? on individual quantifiers)

A

anchored at the start of the subject

The e modifier, which evaluated the replacement as PHP code, was removed in PHP 7.0; use preg_replace_callback().

The building blocks

Syntax Meaning

.

any character except newline (unless s)

[abc], [^abc], [a-z]

character class, negated class, range

\d \w \s (and \D \W \S)

digit, word character, whitespace (and their negations); ASCII-only without u, Unicode-aware with u

\p{L}, \p{Lu}, \p{N}, \p{Greek}

Unicode property: letter, uppercase letter, number, script (with u)

^, $, \A, \z, \b, \B

start/end of subject (or line with m), absolute start/end, word boundary, non-boundary

*, +, ?, {n}, {n,}, {n,m}

quantifiers: 0+, 1+, 0-1, exactly n, at least n, n to m

*?, +?, ??, {n,m}?

lazy quantifiers: match as little as possible

*+, ++, ?+

possessive quantifiers: never give back what they matched

(…), (?:…), (?<name>…)

capturing group, non-capturing group, named group

\1, \k<name>

backreference to a group

a|b

alternation

(?=…), (?!…)

lookahead, negative lookahead

(?<=…), (?<!…)

lookbehind, negative lookbehind (variable-length since PCRE2 10.43, PHP 8.4)

(?i)

inline modifier for the rest of the group

(?>…)

atomic group: no backtracking into it

Source: Pattern Syntax

Matching: preg_match and preg_match_all

preg_match($pattern, $subject, $matches) returns 1 for a match, 0 for no match and false on error, and fills $matches with the whole match at index 0 and each group after it; named groups appear both by name and by number. preg_match_all() returns the number of matches and collects all of them, grouped by group (PREG_PATTERN_ORDER, the default) or by match (PREG_SET_ORDER).

<?php

declare(strict_types=1);

$isbn = '978-0-00-000001-1';

if (preg_match('/^(?<prefix>97[89])-(?<group>\d{1,5})-/', $isbn, $m) === 1) {
    echo "prefix {$m['prefix']}, registration group {$m['group']}\n";   // prefix 978, registration group 0
}

$text = 'Solaris 1961, Kindred 1979, Dawn 1987';

preg_match_all('/\b\d{4}\b/', $text, $years);
echo implode(' ', $years[0]), "\n";                                   // 1961 1979 1987

preg_match_all('/(?<title>\w+) (?<year>\d{4})/', $text, $books, PREG_SET_ORDER);
foreach ($books as $book) {
    echo "{$book['title']} => {$book['year']}\n";
}

// Offsets: PREG_OFFSET_CAPTURE returns [text, byte offset] pairs
preg_match('/Kindred/', $text, $m, PREG_OFFSET_CAPTURE);
var_dump($m[0]);   // ["Kindred", 14]

Compare the result with === 1: preg_match() returns false when the pattern is invalid or the engine gives up, and a bare if (preg_match(…)) treats that error as "no match".

Greedy, lazy and possessive

Quantifiers are greedy by default: they match as much as possible and then give back characters (backtrack) if the rest of the pattern fails. Adding ? makes them lazy.

<?php

declare(strict_types=1);

$html = '<b>Kindred</b> and <b>Dawn</b>';

preg_match('/<b>.+<\/b>/', $html, $greedy);
preg_match('/<b>.+?<\/b>/', $html, $lazy);

var_dump($greedy[0]);   // "<b>Kindred</b> and <b>Dawn</b>" -- as much as possible
var_dump($lazy[0]);     // "<b>Kindred</b>"                 -- as little as possible

Do not parse HTML or XML with regular expressions beyond trivial cases like this one; use the DOM extension (Dom\HTMLDocument in 8.4), covered in JSON, XML and Serialization.

Source: Repetition

Lookarounds

Lookarounds assert that something does or does not come before or after the current position, without consuming it. Since PHP 8.4 (PCRE2 10.43 and later) lookbehinds can have variable length, so alternatives of different lengths such as Mr|Mrs|Dr are allowed:

<?php

declare(strict_types=1);

preg_match('/\d+(?= copies)/', '3 copies left', $m);
var_dump($m[0]);                                         // "3"   lookahead

preg_match('/(?<!\$)\b\d+\b/', 'fine $12, copies 7', $m);
var_dump($m[0]);                                         // "7"   negative lookbehind: not a price

preg_match('/(?<=\b(?:Mr|Mrs|Dr)\. )\w+/', 'Dr. Lem', $m);
var_dump($m[0]);                                         // "Lem" variable-length lookbehind (8.4)

Source: Assertions

Unicode

Without the u modifier, PCRE works on bytes: . matches one byte of a multi-byte character, and \w does not match ó. With u, pattern and subject are UTF-8, \p{…} properties work, PHP also turns on Unicode semantics for \d, \w, \s and \b (PCRE2’s UCP option), and offsets are still reported in bytes.

<?php

declare(strict_types=1);

var_dump(preg_match('/^.$/', 'é'));                    // int(0) -- é is two bytes
var_dump(preg_match('/^.$/u', 'é'));                   // int(1)

preg_match_all('/\p{Lu}\p{Ll}+/u', 'łódź Łódź Kraków', $m);
print_r($m[0]);                                        // ["Łódź", "Kraków"]: capitalised words

var_dump(preg_match('/\w+/', 'Łódź', $m), $m[0]);      // int(1) "d": without u, \w matches ASCII letters only
var_dump(preg_match('/\w+/u', 'Łódź', $m), $m[0]);     // int(1) "Łódź": with u, \w, \d and \b are Unicode-aware

Replacing

<?php

declare(strict_types=1);

// Backreferences in the replacement: $1 or ${1} (or \1)
echo preg_replace('/(\w+), (\w+)/', '$2 $1', 'Butler, Octavia'), "\n";        // Octavia Butler
echo preg_replace('/\s+/', ' ', "too   many \n spaces"), "\n";               // too many spaces

// A callback computes each replacement
echo preg_replace_callback(
    '/\b(\d{4})\b/',
    fn (array $m): string => (string) ((int) $m[1] + 100),
    'Solaris 1961',
), "\n";                                                                     // Solaris 2061

// Several patterns, each with its own callback, applied in order (7.0)
echo preg_replace_callback_array([
    '/\d+/' => fn (array $m): string => "<{$m[0]}>",
    '/[aeiou]/' => fn (array $m): string => strtoupper($m[0]),
], 'shelf 12'), "\n";                                                        // shElf (12)

// Limit and count
echo preg_replace('/a/', 'A', 'banana', 2, $count), " ({$count})\n";         // bAnAna (2)

In the replacement string, $ and \ are special; when inserting user-provided text, use a callback rather than interpolating it into the replacement. Pattern arrays with replacement arrays are applied sequentially, like str_replace().

Splitting, filtering and quoting

<?php

declare(strict_types=1);

print_r(preg_split('/[\s,]+/', 'sf, fantasy  poetry,crime', -1, PREG_SPLIT_NO_EMPTY));
// ["sf", "fantasy", "poetry", "crime"]

print_r(preg_split('/(\d+)/', 'a1b22c', -1, PREG_SPLIT_DELIM_CAPTURE));
// ["a", "1", "b", "22", "c"]: captured delimiters are kept

print_r(preg_grep('/^978-/', ['978-1', '979-2', '978-3']));    // keys preserved: [0 => "978-1", 2 => "978-3"]

// preg_quote escapes special characters, including the delimiter you pass
$userInput = 'C++ (2nd ed.)';
$pattern = '/' . preg_quote($userInput, '/') . '/i';
var_dump($pattern, preg_match($pattern, 'Learning c++ (2nd ED.)'));

Whenever user input becomes part of a pattern, pass it through preg_quote() with the delimiter; otherwise the user controls the regular expression, which allows both wrong matches and denial-of-service patterns.

Errors and catastrophic backtracking

When a pattern cannot be compiled, PHP emits a warning and the function returns false (or null for preg_replace()). When matching fails for an engine reason — too much backtracking, a JIT stack overflow, invalid UTF-8 with u — the function returns false without a warning. preg_last_error() returns the error code and preg_last_error_msg() (8.0) a readable message.

A pattern with nested quantifiers, such as (a+)+, can need exponential time on a subject that almost matches: the engine tries every way of splitting the as between the inner and outer loops. PCRE stops after pcre.backtrack_limit steps (1,000,000 by default; pcre.recursion_limit and the JIT stack size are the related limits). This is the basis of ReDoS attacks.

<?php

declare(strict_types=1);

$evil = '/^(a+)+$/';
$subject = str_repeat('a', 40) . 'b';

$result = preg_match($evil, $subject);
var_dump($result, preg_last_error() === PREG_BACKTRACK_LIMIT_ERROR, preg_last_error_msg());
// bool(false) bool(true) string(25) "Backtrack limit exhausted"

// The fix: remove the ambiguity (or use a possessive quantifier / atomic group)
var_dump(preg_match('/^a+$/', $subject), preg_match('/^(?>a+)+$/', $subject));   // int(0) int(0)

var_dump(preg_match('/x/u', "\xff"), preg_last_error_msg());    // bool(false) "Malformed UTF-8 characters, possibly incorrectly encoded"

Defences: avoid nested quantifiers over the same characters, prefer specific classes ([^"]*) to .*, use atomic groups or possessive quantifiers, keep pcre.backtrack_limit at its default, and always check for false.

When not to use a regular expression

  • Fixed substrings: str_contains(), str_starts_with(), str_replace() and strtr() are faster and clearer.

  • Validation of e-mail addresses, URLs, IPs and integers: filter_var() with FILTER_VALIDATE_EMAIL, FILTER_VALIDATE_URL, FILTER_VALIDATE_IP, FILTER_VALIDATE_INT.

  • URLs: the URI extension (Uri\Rfc3986\Uri, 8.5) or parse_url().

  • HTML, XML and JSON: the DOM extension and json_decode().

  • CSV: str_getcsv() and fgetcsv().

Where older books and tutorials are outdated

  • POSIX regular expressions (ereg(), eregi(), split()) were removed in PHP 7.0 (Kromann, Nixon mention them as an alternative).

  • The /e modifier was removed in 7.0; use preg_replace_callback().

  • PCRE 8 versus PCRE2: PHP 7.3 switched to PCRE2, which is stricter about some invalid patterns (for example an unescaped - in the middle of a character class after a class shorthand). PHP 8.4’s PCRE2 10.44 added variable-length lookbehind.

  • preg_last_error_msg() (8.0) and the n modifier (8.2) are absent from older books.