PCRE and Regular Expressions: Syntax, Functions, and Performance

Write and reason about PHP regular expressions using the PCRE engine -- delimiters, modifiers, character classes, groups, alternation, quantifiers, lookaround, and backreferences -- and apply preg_match, preg_match_all, preg_replace, preg_replace_callback, and preg_split correctly, while recognizing and avoiding catastrophic backtracking.

PHP's regular expression support is built on PCRE (Perl-Compatible Regular Expressions), one of the most fully-featured regex engines available, exposed through the preg_*() function family. Regular expressions let you describe a pattern of text -- 'a capital letter followed by one or more lowercase letters', 'a valid-looking email address', 'three digits, a dash, then four digits' -- and then search for it, validate against it, extract pieces of it, or replace it, all without writing character-by-character parsing logic yourself. This is a genuinely large topic, so this module works through PCRE syntax piece by piece: delimiters and modifiers, character classes and quantifiers, capturing versus non-capturing versus named groups, lookahead and lookbehind, and the practical preg_*() functions -- finishing with catastrophic backtracking, a real performance and availability risk that badly-written patterns can cause.

Delimiters, Modifiers, and Character Classes

Every PCRE pattern in PHP is wrapped in a pair of delimiters -- most commonly forward slashes, as in '/pattern/', though any non-alphanumeric, non-whitespace character works (# and ~ are common alternatives when the pattern itself needs to contain literal slashes, to avoid escaping every one). After the closing delimiter, you can append modifiers that change how the whole pattern behaves: i makes matching case-insensitive, m makes ^ and $ match the start/end of each line within a multi-line string rather than only the start/end of the whole string, s makes . match newline characters too (normally it doesn't), u treats the pattern and subject as UTF-8 and enables proper Unicode-aware matching, and x (extended mode) lets you write whitespace and # comments inside the pattern for readability, which PCRE then ignores as literal characters.

A character class, written in square brackets like [A-Za-z0-9_], matches exactly one character from the set listed inside it. Ranges (A-Z, 0-9) and individual characters can be combined freely, and a leading ^ inside the brackets negates the class -- [^0-9] matches any single character that is NOT a digit. PCRE also provides shorthand classes: \d for a digit (equivalent to [0-9] by default), \w for a 'word' character (letters, digits, underscore), \s for whitespace, and their uppercase negations \D, \W, \S for 'not' versions of each.

The example given in the spec, preg_match('/^[A-Z][a-z]+$/', $name), demonstrates several of these pieces together: ^ and $ anchor the match to the entire string (not just a substring somewhere inside it), [A-Z] matches exactly one uppercase letter, and [a-z]+ matches one or more lowercase letters after it -- so the whole pattern matches strings like 'Ada' or 'Grace' but rejects 'ada' (no leading capital), 'ADA' (not all-lowercase after the first letter), or 'Ada Lovelace' (contains a space, which isn't in either character class, and the $ anchor requires the match to reach the very end).

• Delimiters wrap every pattern: /pattern/ is most common; #pattern# avoids escaping literal slashes inside it. • Modifiers go after the closing delimiter: i (case-insensitive), m (multiline ^/$), s (dot matches newline), u (UTF-8/Unicode), x (extended, allows whitespace/comments). • [A-Za-z0-9_] is a character class: matches exactly one character from the listed set; [^...] negates it. • Shorthand classes: \d digit, \w word character, \s whitespace, and \D \W \S as their negations. • ^ and $ anchor to the start/end of the string (or line, under the m modifier) -- without them a pattern can match anywhere inside the subject.

Groups, Alternation, and Quantifiers

Parentheses (...) create a capturing group: a sub-match that's extracted separately into the $matches array when the surrounding pattern matches. A non-capturing group, (?:...), groups a sub-pattern for the purposes of applying a quantifier or alternation to it as a unit, without adding an entry to $matches -- useful when you need grouping's structural effect but don't actually care about extracting that piece. A named group, (?<name>...) (or the equivalent (?P<name>...)), behaves exactly like a normal capturing group but lets you access the captured text by a readable name ($matches['name']) instead of only by numeric position, which is far more maintainable once a pattern has more than two or three groups.

Alternation, written with |, tries each alternative in order and matches the first one that succeeds -- cat|dog|bird matches any one of those three literal words. Alternation's scope is usually bounded by grouping: gr(a|e)y matches 'gray' or 'grey', because the parentheses limit which part the | applies to; without them, gra|ey would mean 'gra' OR 'ey' entirely, a very different pattern.

Quantifiers control how many times the preceding token may repeat: * means zero or more, + means one or more, ? means zero or one (optional), and {n,m} means between n and m repetitions (with {n} for exactly n, and {n,} for n or more). By default quantifiers are greedy -- they consume as much as possible and only give characters back if that's required for the rest of the pattern to match. Appending ? to a quantifier (*?, +?, ??) makes it lazy instead, consuming as little as possible and expanding only when forced to. The difference is dramatic on a pattern like <.+> against '<b>bold</b>': greedy matches the entire string from the first < to the very last >, while <.+?> matches just '<b>', stopping at the first >.

• (...) capturing group -- extracted into numbered entries in $matches. • (?:...) non-capturing group -- groups for quantifier/alternation scope only, no $matches entry. • (?<name>...) named group -- captured, and also accessible as $matches['name']. • | alternation tries each option in order; scope it with grouping to avoid surprising precedence. • Quantifiers: * (0+), + (1+), ? (0 or 1), {n,m} (n to m) -- greedy by default, lazy with a trailing ? (*?, +?).

Lookaround and Backreferences

A lookahead asserts that something does (or doesn't) follow the current position, without actually consuming those characters as part of the match. Positive lookahead, (?=...), requires the pattern inside it to match right after the current position -- foo(?=bar) matches 'foo' only when it's immediately followed by 'bar', but the matched text is just 'foo', not 'foobar'. Negative lookahead, (?!...), requires the opposite: foo(?!bar) matches 'foo' only when it's NOT immediately followed by 'bar'.

A lookbehind works the same way but checks what precedes the current position: positive lookbehind (?<=...) requires the given pattern to appear immediately before, and negative lookbehind (?<!...) requires that it doesn't. (?<=\$)\d+ matches a run of digits only when immediately preceded by a dollar sign, without including the $ itself in the match -- extremely useful for pulling a numeric amount out of '$42' as just '42'. PCRE generally requires lookbehind patterns to be of fixed (or, in modern PCRE2, bounded) length, unlike lookahead which has no such restriction.

A backreference, \1, \2 (or \k<name> for a named group), refers back to whatever a previous group actually captured, requiring the same text to repeat later in the match -- (\w+)\s+\1 matches a repeated word like 'the the' (capturing 'the', then requiring the identical text again after whitespace), which is exactly the pattern you'd use to catch an accidentally doubled word in text. Backreferences match previously captured text, not the group's pattern re-evaluated -- (\d{3})\1 requires the literal same three digits to repeat, like '123123', not any two different three-digit sequences.

• (?=...) positive lookahead: must be followed by this, but it's not part of the match itself. • (?!...) negative lookahead: must NOT be followed by this. • (?<=...) positive lookbehind / (?<!...) negative lookbehind: same idea, checking what precedes the position. • \1, \2 (or \k<name>) backreference: requires the exact text a group captured to appear again. • Lookaround doesn't consume characters -- it only asserts a condition about neighboring text.

The preg_*() Functions, Errors, and Catastrophic Backtracking

preg_match($pattern, $subject, $matches) tests whether the pattern matches anywhere in $subject (unless anchored with ^/$) and, if it does, fills $matches with the full match at index 0 and each capturing group after it; it returns 1 for a match, 0 for no match, and false only on an actual regex error (a case that's easy to confuse with 'no match' if you use == instead of a strict comparison). preg_match_all() finds every non-overlapping match in the subject rather than stopping at the first one, filling $matches as an array of arrays (by default grouped by pattern group, or by match with the PREG_SET_ORDER flag, which is usually more intuitive to iterate).

preg_replace($pattern, $replacement, $subject) replaces every match with $replacement, where $1, $2 (or ${1}) inside the replacement string refer back to captured groups from the match. preg_replace_callback($pattern, $callback, $subject) is the more powerful sibling: instead of a static replacement string, it calls your callback with the match array for every match found and substitutes in whatever string the callback returns -- essential whenever the replacement needs actual logic (like uppercasing a captured group, looking something up, or doing arithmetic) rather than a fixed template. preg_split($pattern, $subject) splits a string wherever the pattern matches, similar to explode() but with full regex power as the delimiter instead of a fixed substring.

Every preg_*() call can fail for reasons other than 'no match' -- a malformed pattern, a backtracking limit exceeded, or a UTF-8 validity failure under the u modifier. preg_last_error() returns an error code (PREG_NO_ERROR, PREG_BACKTRACK_LIMIT_ERROR, PREG_BAD_UTF8_ERROR, and others) after any preg_*() call, and preg_last_error_msg() (PHP 8+) gives a human-readable message -- checking this after a preg_match() that unexpectedly returns false (not 0) is what actually diagnoses the problem instead of silently treating it as 'just didn't match'.

Catastrophic backtracking is a performance (and denial-of-service) hazard caused by ambiguous, nested repetition in a pattern -- classically, something like (a+)+$ or (a|aa)+$ against a long string of a's that ultimately fails to match. Because the engine can partition the same run of characters among the nested quantifiers in exponentially many different ways, and it tries them all before giving up, matching time can explode from milliseconds to minutes (or effectively forever) as the input grows by just a few characters -- this is exactly what 'ReDoS' (Regular expression Denial of Service) exploits when an attacker controls the input to a vulnerable pattern. The general fix is to eliminate the ambiguity: use possessive quantifiers (a++) or atomic groups ((?>a+)) where backtracking into the inner repetition serves no purpose, tighten character classes so two adjacent quantified groups can't both match the same characters, and always set pcre.backtrack_limit (or trust PHP's default) as a hard ceiling so a pathological pattern fails fast with PREG_BACKTRACK_LIMIT_ERROR instead of hanging the request indefinitely.

• preg_match(): first match + groups into $matches; returns 1 (matched), 0 (no match), or false (regex error -- check with ===). • preg_match_all(): every match; use PREG_SET_ORDER for an array of per-match group arrays, which is usually easier to loop over. • preg_replace(): replaces matches using a template string with $1/$2 backreferences to groups. • preg_replace_callback(): replaces matches using a callback function -- needed whenever the replacement requires logic, not just a template. • preg_last_error() / preg_last_error_msg(): diagnose a false return -- distinguishes an actual regex failure from a simple non-match. • Catastrophic backtracking (nested/ambiguous quantifiers like (a+)+) can make matching time explode exponentially -- the root cause of ReDoS.

Named Groups and preg_match

(?<year>...) both captures numerically ($matches[1]) and by name ($matches['year']). Always compare preg_match()'s return value with === 1 (or === false for errors), not just truthiness. The pattern is anchored (^...$) so it only matches a string that is entirely a date in this exact shape.

$pattern = '/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/';

if (preg_match($pattern, '2026-09-19', $matches) === 1) {
    echo "{$matches['year']}/{$matches['month']}/{$matches['day']}\n";
}

Code Examples

The ^...$ anchors force the entire string to match, not just a piece of it. [A-Z] requires exactly one leading capital, and [a-z]+ requires one or more lowercase letters after it -- 'ada' fails (no capital first), 'ADA' fails (not lowercase after the first letter), 'Ada Lovelace' fails (the space isn't in [a-z], and $ demands the match reach the true end), and 'O' fails ([a-z]+ needs at least one more character). Expected result: Ada: valid ada: invalid ADA: invalid Ada Lovelace: invalid O: invalid return preg_match('/^[A-Z][a-z]+$/', $name) === 1;

PREG_SET_ORDER reorganizes $matches into one sub-array per match (each containing all its named/numbered groups), rather than the default of one array per group across all matches -- which is almost always the easier shape to iterate when you want 'for each log line, give me its fields together'. Expected result: INFO: login at 2026-09-19 08:01:12 ERROR: db_timeout at 2026-09-19 08:02:40 WARN: slow_query at 2026-09-19 08:03:05 preg_match_all($pattern, $log, $matches, PREG_SET_ORDER);

preg_replace() alone can only substitute a fixed string (with $1-style backreferences); here each {{placeholder}} needs different handling (formatting a number vs. escaping a name), which requires actual PHP logic -- exactly the case preg_replace_callback() exists for. The callback receives the full match array for each occurrence and its return value becomes the replacement text. Expected result: Hello Grace, your balance is 1,500.00 dollars.

preg_replace_callback('/\{\{(\w+)\}\}/', function (array $m) use ($data): string { ... }, $template);

Each (?=...) lookahead independently asserts a condition (contains a digit somewhere ahead, contains an uppercase letter somewhere ahead) without consuming any characters, so .{8,} at the end can still independently require the overall length -- all three conditions apply to the same starting position rather than eating into each other's territory. The === false check (distinct from === 0) is what would catch an actual engine-level failure rather than a simple non-match. Expected result: bool(false) bool(false) bool(true)

$pattern = '/^(?=.*\d)(?=.*[A-Z]).{8,}$/';

In the vulnerable pattern, (a+)+ lets the inner a+ and the outer + both claim portions of the same run of a's in an enormous number of equivalent ways; when the trailing $ ultimately can't match (because of the '!'), the engine backtracks through nearly all of those partitions before giving up -- time grows exponentially with input length. The atomic group (?>a+) forbids backtracking back into what it already matched, so failure is detected immediately instead of after an exponential search.

$fixed = '/^(?>a+)$/';

Common mistake: Writing a validation regex with nested repetition like (\w+\s*)+ or (a|aa)+ against user-controlled input -- an attacker who notices this can send a crafted string that makes a single request pin a CPU core for minutes, a classic ReDoS attack.

<?php
declare(strict_types=1);

function isCapitalizedName(string $name): bool
{
    return preg_match('/^[A-Z][a-z]+$/', $name) === 1;
}

foreach (['Ada', 'ada', 'ADA', 'Ada Lovelace', 'O'] as $candidate) {
    $result = isCapitalizedName($candidate) ? 'valid' : 'invalid';
    echo "{$candidate}: {$result}\n";
}
<?php
declare(strict_types=1);

$log = "[2026-09-19 08:01:12] INFO login\n" .
       "[2026-09-19 08:02:40] ERROR db_timeout\n" .
       "[2026-09-19 08:03:05] WARN slow_query\n";

$pattern = '/\[(?<ts>[\d\- :]+)\]\s+(?<level>[A-Z]+)\s+(?<event>\w+)/';

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

foreach ($matches as $entry) {
    echo "{$entry['level']}: {$entry['event']} at {$entry['ts']}\n";
}
<?php
declare(strict_types=1);

$template = "Hello {{name}}, your balance is {{balance}} dollars.";

$data = ['name' => 'Grace', 'balance' => 1500];

$rendered = preg_replace_callback(
    '/\{\{(\w+)\}\}/',
    function (array $m) use ($data): string {
        $key = $m[1];
        if ($key === 'balance') {
            return number_format((float) $data[$key], 2);
        }
        return htmlspecialchars((string) ($data[$key] ?? ''));
    },
    $template,
);

echo $rendered;
<?php
declare(strict_types=1);

function isStrongPassword(string $password): bool
{
    // Must be 8+ chars, contain a digit, and contain an uppercase letter --
    // each requirement expressed as a lookahead so none of them consume characters.
    $pattern = '/^(?=.*\d)(?=.*[A-Z]).{8,}$/';

    $result = preg_match($pattern, $password);

    if ($result === false) {
        throw new RuntimeException(
            'Regex failed: ' . preg_last_error_msg()
        );
    }

    return $result === 1;
}

var_dump(isStrongPassword('short1A'));      // false -- under 8 characters
var_dump(isStrongPassword('longenough'));   // false -- no digit, no uppercase
var_dump(isStrongPassword('LongEnough1'));  // true
<?php
declare(strict_types=1);

// VULNERABLE: nested quantifiers create exponential ambiguity when the
// overall match ultimately fails (note the trailing '!' that can never match).
$vulnerable = '/^(a+)+$/';

// A long run of 'a' followed by one non-'a' character makes this pathological:
// on real inputs this can take seconds to minutes as the string grows by a few chars.
// $subject = str_repeat('a', 30) . '!';
// preg_match($vulnerable, $subject); // DO NOT actually run this against long input

// FIXED: an atomic group commits to what (a+) already consumed, forbidding the
// engine from re-partitioning that same run of 'a's in exponentially many ways.
$fixed = '/^(?>a+)$/';

$safeSubject = str_repeat('a', 30) . '!';
$start = microtime(true);
preg_match($fixed, $safeSubject);
printf("Fixed pattern took %.4fs (fails fast, no backtracking explosion)\n", microtime(true) - $start);

preg_match Anatomy: /^[A-Z][a-z]+$/ against 'Ada'

Anchors force the whole string to satisfy the pattern, not just some substring within it.

Pattern:  /  ^   [A-Z]   [a-z]+   $  /
             |     |       |      |
             |     |       |      +-- end-of-string anchor
             |     |       +--------- one-or-more lowercase letters
             |     +----------------- exactly one uppercase letter
             +----------------------- start-of-string anchor

Subject:  A   d   a
          ^   ^^^
        [A-Z] [a-z]+  -> match succeeds, $ reached at end of string

Important Rules

Always compare preg_match()'s return value with === (1, 0, or false) -- false means an engine error, not 'no match'.

Anchor a pattern with ^ and $ whenever it must validate an entire string, not just find a match somewhere inside it.

Use non-capturing groups (?:...) when you need grouping but don't need the sub-match extracted.

Prefer named groups (?<name>...) over numeric indexes once a pattern has more than two or three groups.

Use preg_replace_callback(), not preg_replace(), whenever the replacement needs logic rather than a fixed template.

Avoid nested, ambiguous quantifiers like (a+)+ or (\w+\s*)* on any pattern that runs against untrusted input -- use atomic groups/possessive quantifiers or restructure the pattern instead.

Check preg_last_error()/preg_last_error_msg() whenever a preg_*() call unexpectedly returns false.

Why This Matters in Production

Regex validation sits directly in the request path of almost every web application -- validating emails, usernames, URLs, form fields -- which means a pathological pattern isn't just a correctness bug, it's a denial-of-service vector: a single crafted input against a vulnerable pattern like (a+)+ can pin a worker process's CPU for minutes, and if that worker handles other requests, the whole application slows or stalls for everyone. Real incidents have been caused by exactly this in widely-used libraries' email- or URL-validation regexes. Named groups, preg_replace_callback(), and preg_last_error() checks aren't just style preferences either -- they're what makes a regex-heavy codebase debuggable months later, instead of a wall of \1, \2, \3 nobody wants to touch.

if (preg_match('/^\d+$/', $input)) {... } // truthy check treats both 0 and false the same way if (preg_match('/^\d+$/', $input) === 1) {... } // distinguishes 'matched' from both 'no match' (0) and 'engine error' (false) preg_match() returns an int (0 or 1) on success but false on an actual regex failure. A loose truthy check treats 0 (falsy) correctly by accident but can't tell a real 'no match' apart from a silent engine error, which hides bugs like a malformed pattern or a backtracking-limit failure.

'/^(\w+\s*)+$/' // validates 'a sentence of words', but nested quantifiers create catastrophic backtracking on a long non-matching input

'/^\w+(?:\s+\w+)*$/' // a single top-level quantifier around a non-capturing group -- no ambiguous nested repetition over the same characters (\w+\s*)+ lets the outer + and inner \w+/\s* both claim overlapping portions of the same text in exponentially many ways when the overall match ultimately fails. Restructuring so only one quantifier governs any given stretch of characters removes the ambiguity and keeps matching time linear.

[multiple-choice] In the pattern /^[A-Z][a-z]+$/ applied to preg_match(), why does it reject the string 'ADA'? A. The ^ anchor doesn't allow uppercase letters at all B. [A-Z] matches only the first 'A', but [a-z]+ then requires one or more LOWERCASE letters for the rest of the string, and 'DA' is uppercase -- so the $ anchor is never reached with a full match C. preg_match() is case-insensitive by default, so it can never distinguish case D. The + quantifier requires exactly one repetition, not one or more

B. [A-Z] matches only the first 'A', but [a-z]+ then requires one or more LOWERCASE letters for the rest of the string, and 'DA' is uppercase -- so the $ anchor is never reached with a full match — [A-Z] consumes exactly the first character (which must be uppercase), and [a-z]+ then requires every remaining character up to the $ anchor to be lowercase. 'ADA' fails because 'DA' after the first letter isn't lowercase, so the pattern can't match the entire string.

[multiple-choice] What is the key difference between preg_replace() and preg_replace_callback()? A. preg_replace_callback() is only for removing matches, not replacing them B. preg_replace() substitutes using a fixed template string (with $1/$2 backreferences); preg_replace_callback() calls a function per match, letting the replacement be computed with arbitrary logic C. They are functionally identical, just different syntax D. preg_replace() supports named groups but preg_replace_callback() does not

B. preg_replace() substitutes using a fixed template string (with $1/$2 backreferences); preg_replace_callback() calls a function per match, letting the replacement be computed with arbitrary logic — preg_replace() can only fill a static template with captured groups. preg_replace_callback() runs your callback for every match and uses its return value, which is necessary any time the replacement needs computation (formatting, lookups, conditional logic) rather than a fixed pattern.

[multiple-choice] What causes catastrophic backtracking, and what is one standard fix? A. Using the u modifier for Unicode; fixed by removing it B. Ambiguous, nested quantifiers (like (a+)+) that let the engine partition the same characters among multiple quantifiers in exponentially many ways when the overall match fails; fixed with atomic groups (?>...) or possessive quantifiers (a++) C. Using too many capturing groups; fixed by using only non-capturing groups D. Using $ instead of \z as the end anchor

B. Ambiguous, nested quantifiers (like (a+)+) that let the engine partition the same characters among multiple quantifiers in exponentially many ways when the overall match fails; fixed with atomic groups (?>...) or possessive quantifiers (a++) — Nested or overlapping quantifiers create exponentially many ways to divide the same run of characters among them; when the match ultimately fails, the engine tries nearly all of those divisions before giving up. Atomic groups and possessive quantifiers forbid backtracking into an already-matched segment, eliminating the ambiguity and restoring near-linear matching time.

Write a function extractHashtags(string $text): array that returns every hashtag in $text (a '#' followed by one or more word characters) as plain strings without the leading '#', using preg_match_all(). Then write a second function, isValidSlug(string $slug): bool, using an anchored pattern to check that a string is entirely lowercase letters, digits, and hyphens (e.g. 'my-post-title-2').

function extractHashtags(string $text): array { preg_match_all('/#(\w+)/', $text, $m); return $m[1]; } function isValidSlug(string $slug): bool { return preg_match('/^[a-z0-9-]+$/', $slug) === 1; }

For extractHashtags, capture the part after # in a group and use PREG_PATTERN_ORDER's group index (or just build the array from the captured group). For isValidSlug, anchor with ^ and $ and use a character class like [a-z0-9-]+.