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
<?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
Output
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

PatternMatches
\d \w \sA 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
\bWord boundary
(…) (?<name>…)Capture group; named group
a|bEither alternative
ModifierEffect
iCase-insensitive
m^ and $ match at line breaks
s. matches newlines too
xIgnore whitespace in the pattern, allowing comments
uTreat 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
<?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";
}
Output
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
<?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";
}
Output
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
<?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";
Output
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
<?php

print_r(preg_split('/[\s,;]+/', "red, green;blue  yellow"));
print_r(preg_split('//u', "añb", -1, PREG_SPLIT_NO_EMPTY));
Output
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
<?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";
Output
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
<?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."));
Output
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.

Try it yourself · main.php
Edit the code, copy it into main.php, then run it with php main.php (PHP 8.5). How to install PHP
Output
Found 12.50
Found 3.25
Found 6.75
Total: $22.50