PHP Regex
A regular expression (regex) is a compact pattern language for describing text: matching it, pulling pieces out of it, or replacing it. PHP does not ship its own regex engine – instead it wraps the industry-standard PCRE (Perl Compatible Regular Expressions) library through the preg_* family of functions. Regex shows up constantly in real PHP code: validating form input, parsing log lines, scraping and cleaning HTML, generating URL slugs, and reshaping user-submitted text. Once you are comfortable with it, you can express in a single line what would otherwise take a dozen calls to strpos() and substr().
Overview: How Regex Works in PHP
Every preg_* function takes a pattern as its first argument: a PHP string that looks like '/^[a-z]+$/i'. Internally, PHP hands that string to the PCRE library, which compiles it into a small piece of bytecode describing the pattern as a state machine. That compiled form is then used to scan your subject string. PHP keeps a small internal cache of compiled patterns, so calling the same pattern string repeatedly (for example, inside a loop) does not pay the full compilation cost every time.
The matching itself is done by a backtracking engine. Unlike a simple linear scanner, PCRE tries to match the pattern starting at each position in the subject string, and when a piece of the pattern (usually a quantifier like * or +) can match a variable number of characters, the engine first tries to consume as much as possible and then backtracks – giving characters back one at a time – until the rest of the pattern also matches. This is why greedy quantifiers can behave surprisingly (see Common Mistakes below) and why deeply nested quantifiers can make a pattern slow on adversarial input.
PCRE Replaced POSIX Regex
Older PHP code sometimes used the POSIX-style ereg() and eregi() functions. Those were removed in PHP 7 – today preg_* (PCRE) is the only regex API in PHP, and it is both faster and considerably more powerful than the old POSIX functions were.
Syntax
A PHP regex pattern is a string with three parts: an opening delimiter, the pattern body, a closing delimiter, and zero or more modifier letters immediately after the closing delimiter – for example '/^[a-z0-9_-]+$/i'.
- Delimiter – almost any non-alphanumeric, non-whitespace character.
/is the convention, but#or~are handy when the pattern itself contains slashes (file paths, URLs), since you avoid having to escape them. - Pattern body – the actual regular expression: literal characters, character classes, quantifiers, groups, and anchors.
- Modifiers – single letters after the closing delimiter that change how the whole pattern behaves (case sensitivity, multiline mode, and so on).
The core functions you will use are:
| Function | Purpose | Returns |
|---|---|---|
preg_match() |
Tests a pattern against a string and captures the first match | 1, 0, or false on error |
preg_match_all() |
Finds every match in a string | Number of matches, or false |
preg_replace() |
Replaces matches with a literal or backreferenced string | Modified string (or array) |
preg_replace_callback() |
Replaces matches using the return value of a callback | Modified string (or array) |
preg_split() |
Splits a string wherever the pattern matches | Array of pieces |
preg_quote() |
Escapes regex metacharacters in a plain string | Escaped string |
Delimiters
Any of these are valid: /pattern/, #pattern#, ~pattern~, %pattern%. Pick whichever one appears least often inside the pattern itself, so you don’t have to backslash-escape it.
Modifiers
| Modifier | Effect |
|---|---|
i |
Case-insensitive matching |
m |
Multiline mode: ^ and $ match at line boundaries, not just string boundaries |
s |
Dot-all mode: . also matches newline characters |
x |
Extended mode: whitespace and # comments inside the pattern are ignored, for readability |
u |
Treats the pattern and subject as UTF-8 |
Metacharacters, Character Classes & Quantifiers
Inside the pattern body, most characters match themselves literally. The characters below have special meaning:
| Token | Meaning |
|---|---|
^ $ |
Start / end of string (or line, with m) |
. |
Any character except newline (unless s is set) |
\d \w \s |
Digit, word character, whitespace (and \D \W \S for the negation) |
[...] |
Character class, e.g. [a-f0-9] |
* + ? |
Zero-or-more, one-or-more, zero-or-one |
{n,m} |
Between n and m repetitions |
(...) |
Capturing group |
(?:...) |
Non-capturing group |
(?<name>...) |
Named capturing group |
| |
Alternation (OR) |
Examples
Example 1: Validating a Username with preg_match()
<?php
$usernames = ['john_doe', 'j', 'user-name!', 'valid_user123'];
foreach ($usernames as $name) {
if (preg_match('/^[a-zA-Z0-9_]{3,16}$/', $name) === 1) {
echo "$name is valid\n";
} else {
echo "$name is invalid\n";
}
}
Output:
john_doe is valid
j is invalid
user-name! is invalid
valid_user123 is valid
preg_match() returns 1 if the pattern matches (here, the whole string, thanks to the ^ and $ anchors), 0 if it does not, and false only on an internal error such as a malformed pattern. The character class [a-zA-Z0-9_] allows letters, digits, and underscores, and {3,16} requires the whole string to be between 3 and 16 of those characters – which is why "j" (too short) and "user-name!" (disallowed characters) fail.
Example 2: Extracting Dates with Named Capture Groups
<?php
$text = "Meeting on 2026-07-25 and follow-up on 2026-08-01.";
preg_match_all('/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/', $text, $matches, PREG_SET_ORDER);
foreach ($matches as $m) {
echo "Found date: {$m['month']}/{$m['day']}/{$m['year']}\n";
}
Output:
Found date: 07/25/2026
Found date: 08/01/2026
preg_match_all() scans the whole subject and returns every match, not just the first. Giving each group a name with (?<name>...) means you can read $m['month'] instead of the harder-to-follow $m[2] – named groups are still available by numeric index too, PHP simply adds the name as an extra key. PREG_SET_ORDER arranges $matches as one array per match, which is usually more convenient than the default PREG_PATTERN_ORDER when you plan to loop over the results.
Example 3: Masking Sensitive Data with preg_replace_callback()
<?php
$text = "Card numbers: 4111111111111111 and 5500005555555559.";
$masked = preg_replace_callback('/\d{12}(\d{4})/', function (array $m): string {
return str_repeat('*', 12) . $m[1];
}, $text);
echo $masked;
Output:
Card numbers: ************1111 and ************5559.
preg_replace() is fine when the replacement is a fixed string or a simple backreference like $1, but when the replacement needs real logic, preg_replace_callback() is the right tool. Here \d{12}(\d{4}) matches any run of 16 digits, capturing only the last 4, and the callback rebuilds the replacement as 12 asterisks plus that captured group – masking every match found, however many there are.
How the Backtracking Engine Works, Step by Step
It helps to trace a small example by hand. Take the pattern ab*c against the subject "abbbc":
- The engine tries to align the start of the pattern at position 0 of the subject.
amatches the literala. b*is greedy, so it consumes as manybcharacters as it can – all threebs – moving the internal position to just before thec.- The engine then tries to match the final
cin the pattern against the next character in the subject. It succeeds immediately, so no backtracking is needed and the whole match succeeds as"abbbc".
Now imagine the subject were "abbbx" instead. After b* greedily consumes all three bs, the pattern’s final c would need to match x, which fails. The engine then backtracks: it gives back one b and tries c against the third b – still no match – and keeps giving back characters one at a time until it has given back all three, at which point the whole attempt at position 0 fails and the engine moves on to try starting the match at position 1, and so on until either a match is found or every starting position has been exhausted.
This backtracking is what makes regex so flexible, but it is also what makes certain patterns dangerous. A pattern with nested repetition, such as (a+)+b, can force the engine into an exponential number of backtracking attempts on input that almost – but doesn’t quite – match, a problem known as catastrophic backtracking or “ReDoS” (regex denial of service). Avoid nesting two quantifiers over the same characters, and be especially careful with patterns built from untrusted input.
Common Mistakes
Mistake 1: Greedy Quantifiers Matching Too Much
A very common bug is using .+ or .* to grab “everything between two markers,” forgetting that greedy quantifiers try to match as much as possible first:
<?php
$html = "<b>Bold</b> and <i>Italic</i>";
preg_match('/<(.+)>/', $html, $matches);
echo $matches[1];
Output:
b>Bold</b> and <i>Italic</i
The intent was to capture the contents of the first tag, but .+ greedily stretches all the way to the last > in the string, then backtracks only as far as it has to. The fix is to either make the quantifier non-greedy with .+?, or – usually better – be explicit about what you don’t want to match, using a negated character class:
<?php
$html = "<b>Bold</b> and <i>Italic</i>";
preg_match('/<([^<>]+)>/', $html, $matches);
echo $matches[1];
Output:
b
[^<>]+ matches one or more characters that are not < or >, so it stops at the very next closing bracket instead of running to the end of the string.
Mistake 2: Splicing Unescaped User Input into a Pattern
Building a pattern by concatenating a variable directly into it is risky, because the variable might contain regex metacharacters the caller never intended as regex syntax:
<?php
$search = "C++"; // user-supplied search term
$text = "I love C++ programming, but C# is different.";
if (preg_match('/' . $search . '/', $text)) {
echo "Found a match for $search\n";
}
Because + is a quantifier, the pattern ends up as /C++/ – two consecutive quantifiers with nothing for the second one to repeat. PCRE refuses to compile it, preg_match() emits a warning and returns false, and the if block never runs. The fix is to escape the dynamic fragment with preg_quote() before splicing it in:
<?php
$search = "C++";
$text = "I love C++ programming, but C# is different.";
$pattern = '/' . preg_quote($search, '/') . '/';
if (preg_match($pattern, $text)) {
echo "Found a match for $search\n";
} else {
echo "No match for $search\n";
}
Output:
Found a match for C++
preg_quote() escapes every character that PCRE treats as special, so the literal text is matched literally – passing the same delimiter you plan to use ('/' here) as the second argument also makes sure the delimiter itself gets escaped if it happens to appear in the input.
Best Practices
- Write patterns as single-quoted strings so PHP doesn’t try to interpolate
$or backslash sequences inside them before PCRE ever sees the pattern. - Anchor with
^and$(or\Aand\zfor stricter string-only anchoring) whenever you need to validate an entire string, not just find a substring somewhere inside it. - Prefer a negated character class like
[^"]+over a bare.+when parsing delimited text – it is both more correct and usually faster, since there is less to backtrack over. - Always run untrusted or dynamic fragments through
preg_quote()before splicing them into a pattern string. - Use named groups,
(?<name>...), once a pattern has more than two or three capturing groups – numeric indexes get hard to track. - Don’t parse HTML or XML with regex. Neither format is a regular language; use
DOMDocumentor a proper parser instead, and reserve regex for well-defined, flat text formats. - Check
preg_match()‘s return value with=== 1(or=== falsefor the error case) rather than a loose truthy check, if you need to tell “no match” apart from “pattern error.” - Watch for nested quantifiers over the same character class (like
(a+)+); they are the classic shape of a catastrophic-backtracking vulnerability.
Practice Exercises
- Write a pattern and use
preg_match()to validate a US ZIP code: either exactly 5 digits, or 5 digits followed by a hyphen and 4 more digits (e.g."90210"or"90210-1234"). Test it against both valid and invalid strings. - Given the string
"Loving this framework! #php #webdev #100DaysOfCode", usepreg_match_all()to extract every hashtag (the#followed by one or more word characters) into an array. - Use
preg_replace_callback()to find every dollar amount in the string"Items: $12.50, $3.00, $45.99"and replace each one with the same amount increased by 10%, rounded to two decimal places.
Summary
- PHP regex is powered by PCRE and exposed through the
preg_*functions –preg_match(),preg_match_all(),preg_replace(),preg_replace_callback(),preg_split(), andpreg_quote(). - A pattern is a delimited string, e.g.
'/pattern/modifiers'; the delimiter can be almost any character, and modifiers likei,m,s, anduchange how the whole pattern behaves. - Matching uses a backtracking engine: greedy quantifiers consume as much as possible first, then give characters back until the rest of the pattern matches.
- Named capture groups (
(?<name>...)) make complex patterns much easier to read than numeric group indexes. - Greedy quantifiers and unescaped dynamic input are the two most common sources of regex bugs – use negated character classes and
preg_quote()to avoid them. - Avoid parsing HTML/XML with regex, and watch for nested quantifiers that can cause catastrophic backtracking.
