Regular Expressions
Regular expressions describe patterns in text — "three digits, a dash, four digits", "a word starting with a capital letter", "anything between square brackets". PHP's preg_* functions use the PCRE engine, the same flavour used by many other languages, to validate, search, extract, replace and split text.
Testing for a match
preg_match returns 1 if the pattern matches and 0 if it doesn't. A pattern is written between delimiters, usually slashes, with optional modifier letters after the closing one:
<?php $codes = ["AB-1234", "ab-1234", "AB-12345", "XY-0042"]; foreach ($codes as $code) { $valid = preg_match('/^[A-Z]{2}-\d{4}$/', $code) === 1; echo str_pad($code, 10), $valid ? "valid" : "invalid", "\n"; } var_dump(preg_match('/^[a-z]{2}-\d{4}$/i', "ab-1234")); // i = case-insensitive
AB-1234 valid ab-1234 invalid AB-12345 invalid XY-0042 valid int(1)
Always write patterns in single quotes, so PHP doesn't interpret backslashes and dollar signs before the regex engine sees them.
Pattern building blocks
| Pattern | Matches |
|---|---|
\d \w \s | A digit; a word character; whitespace (capitals negate: \D, \W, \S) |
. | Any character except a newline |
[abc] [a-z] [^0-9] | One of a set; a range; anything not in the set |
* + ? | Zero or more; one or more; optional |
{3} {2,4} | Exactly 3; 2 to 4 times |
^ $ | Start and end of the string |
\b | Word boundary |
(…) (?<name>…) | Capture group; named group |
a|b | Either alternative |
| Modifier | Effect |
|---|---|
i | Case-insensitive |
m | ^ and $ match at line breaks |
s | . matches newlines too |
x | Ignore whitespace in the pattern, allowing comments |
u | Treat pattern and subject as UTF-8 (use it for any non-ASCII text) |
Capturing groups
Pass a third argument to preg_match and it fills it with the full match and each group. Named groups make the result self-describing:
<?php $log = "2026-10-08 14:32:07 ERROR Disk almost full (97%)"; if (preg_match('/^(?<date>\d{4}-\d{2}-\d{2}) (?<time>[\d:]+) (?<level>[A-Z]+) (?<msg>.*)$/', $log, $m)) { echo "Date: {$m['date']}\n"; echo "Time: {$m['time']}\n"; echo "Level: {$m['level']}\n"; echo "Message: {$m['msg']}\n"; }
Date: 2026-10-08 Time: 14:32:07 Level: ERROR Message: Disk almost full (97%)
Finding every match
preg_match_all finds all matches and returns how many there were:
<?php $text = "Call 555-0142 or 555-0199. Fax: 555-0100."; $count = preg_match_all('/\b\d{3}-\d{4}\b/', $text, $matches); echo "$count numbers: ", implode(", ", $matches[0]), "\n"; preg_match_all('/#(\w+)/u', "Loving #php and #webdev! #100DaysOfCode", $tags); echo implode(", ", $tags[1]), "\n"; preg_match_all('/(\w+)=(\d+)/', "a=1, b=22, c=333", $pairs, PREG_SET_ORDER); foreach ($pairs as [$whole, $key, $value]) { echo "$key is $value\n"; }
3 numbers: 555-0142, 555-0199, 555-0100 php, webdev, 100DaysOfCode a is 1 b is 22 c is 333
By default, $matches[0] holds all full matches and $matches[1] all first groups. PREG_SET_ORDER groups the results by match instead, which is often easier to loop over.
Replacing
preg_replace replaces matches; $1 or ${1} in the replacement refers to a captured group. preg_replace_callback computes each replacement with a function:
<?php echo preg_replace('/\s+/', ' ', "too many spaces"), "\n"; echo preg_replace('/(\d{4})-(\d{2})-(\d{2})/', '$3/$2/$1', "Due: 2026-10-08"), "\n"; echo preg_replace('/\d(?=\d{4})/', '*', "4111111111111234"), "\n"; $prices = "apple 1.20, pear 0.90"; echo preg_replace_callback('/\d+\.\d{2}/', fn($m) => number_format($m[0] * 1.1, 2), $prices), "\n";
too many spaces Due: 08/10/2026 ************1234 apple 1.32, pear 0.99
(?=\d{4}) is a lookahead: it requires four more digits to follow without consuming them, so only digits with at least four after them are masked.
Splitting
<?php print_r(preg_split('/[\s,;]+/', "red, green;blue yellow")); print_r(preg_split('//u', "añb", -1, PREG_SPLIT_NO_EMPTY));
Array
(
[0] => red
[1] => green
[2] => blue
[3] => yellow
)
Array
(
[0] => a
[1] => ñ
[2] => b
)UTF-8 and the u modifier
Without u, the regex engine sees bytes, so . matches half a character and \w doesn't know about accented letters. With u, and Unicode properties such as \p{L} (any letter), patterns work for every language:
<?php $name = "Zoë"; var_dump(preg_match('/^\w+$/', $name)); var_dump(preg_match('/^\p{L}+$/u', $name)); echo preg_replace('/[^\p{L}\p{N}]+/u', '-', "Crème Brûlée: 2 portions!"), "\n";
int(0) int(1) Crème-Brûlée-2-portions-
Readable patterns
The x modifier ignores whitespace and allows # comments inside the pattern, which makes complex expressions maintainable. preg_quote escapes user text so it's matched literally:
<?php $pattern = '/ ^(?<user>[\w.+-]+) # local part @ (?<domain>[\w-]+(\.[\w-]+)+)$ # domain with at least one dot /x'; foreach (["ana.lima@example.com", "not-an-email", "x@y"] as $email) { echo $email, ": ", preg_match($pattern, $email) ? "looks valid" : "invalid", "\n"; } $search = "1+1=2?"; var_dump(preg_match('/' . preg_quote($search, '/') . '/', "Is 1+1=2? Yes."));
ana.lima@example.com: looks valid not-an-email: invalid x@y: invalid int(1)
For validating email addresses in real applications, prefer filter_var($email, FILTER_VALIDATE_EMAIL) (lesson 47) over a home-made pattern.
Try it yourself
Extract every price from a sentence and total them. Then change the pattern to also accept prices without cents.
Found 12.50 Found 3.25 Found 6.75 Total: $22.50