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 |
|---|---|
|
case-insensitive |
|
multiline: |
|
dotall: |
|
extended: whitespace in the pattern is ignored and |
|
UTF-8: pattern and subject are treated as UTF-8 code points, and invalid UTF-8 makes the match fail |
|
no auto-capture (8.2): plain |
|
|
|
ungreedy: inverts the greediness of quantifiers (prefer |
|
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().
Source: Delimiters and Pattern Modifiers
The building blocks
| Syntax | Meaning |
|---|---|
|
any character except newline (unless |
|
character class, negated class, range |
|
digit, word character, whitespace (and their negations); ASCII-only without |
|
Unicode property: letter, uppercase letter, number, script (with |
|
start/end of subject (or line with |
|
quantifiers: 0+, 1+, 0-1, exactly n, at least n, n to m |
|
lazy quantifiers: match as little as possible |
|
possessive quantifiers: never give back what they matched |
|
capturing group, non-capturing group, named group |
|
backreference to a group |
|
alternation |
|
lookahead, negative lookahead |
|
lookbehind, negative lookbehind (variable-length since PCRE2 10.43, PHP 8.4) |
|
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".
Source: preg_match and preg_match_all
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
Source: Unicode character properties
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().
Source: preg_replace, preg_replace_callback and preg_replace_callback_array
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.
Source: preg_split, preg_grep and preg_quote
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()andstrtr()are faster and clearer. -
Validation of e-mail addresses, URLs, IPs and integers:
filter_var()withFILTER_VALIDATE_EMAIL,FILTER_VALIDATE_URL,FILTER_VALIDATE_IP,FILTER_VALIDATE_INT. -
URLs: the URI extension (
Uri\Rfc3986\Uri, 8.5) orparse_url(). -
HTML, XML and JSON: the DOM extension and
json_decode(). -
CSV:
str_getcsv()andfgetcsv().
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
/emodifier was removed in 7.0; usepreg_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 thenmodifier (8.2) are absent from older books.