You need to know whether a string is a valid postcode, pull the year out of a filename, or find every phone number in a block of text. Plain string functions get you part of the way: strpos() finds a fixed word, str_replace() swaps one. The moment the thing you are hunting has a shape rather than a spelling, you want a PHP regex — a small pattern language that describes the shape, and a handful of preg_ functions that run it.
Two of those functions do most of the work. preg_match() asks whether the pattern is in there and hands you the pieces; preg_replace() rewrites them. If you are reaching for a pattern to do jobs the PHP strings functions already do, read that lesson first — a lot of regex code in the wild is one str_replace() written the hard way.
Every PHP regex is a string with delimiters
A pattern is not special syntax. It is an ordinary PHP string, and its first character is the delimiter: the mark that says where the pattern starts and stops. Nearly everyone uses a forward slash, so the pattern for the word flow is '/flow/'. The slashes are the wrapper, not part of what you are matching.
preg_match() returns 1 when it finds the pattern, 0 when it does not, and false when the pattern itself is broken. Three values, not two.
<?php
$pattern = '/flow/';
$subject = 'theflowsyntax.com';
var_dump(preg_match($pattern, $subject)); // 1 — found it
var_dump(preg_match('/FLOW/', $subject)); // 0 — no match, and that is not an error
// the pattern contains slashes, so switch the delimiter instead of escaping them
var_dump(preg_match('#https://#', 'https://theflowsyntax.com'));
Output:
The third call swaps the delimiter to # because the pattern contains slashes of its own. Escaping them as \/\/ works too and reads like a picket fence. Any non-alphanumeric character can be a delimiter, so pick one your pattern does not contain.
Character classes and quantifiers
A class says what kind of character; a quantifier says how many. \d is a digit, \w is a letter, digit or underscore, \s is whitespace, [a-z] is a range you choose, and . is any character except a newline. After any of those, * means none or more, + one or more, ? none or one, and {3,4} a counted range.
<?php
$checks = [
['/\d/', 'Order 66', 'any single digit'],
['/\d+/', 'Order 66', 'one or more digits'],
['/\d{3,4}/', 'Order 66', 'three or four digits in a row'],
['/\s/', 'Order 66', 'any whitespace character'],
['/^\w+$/', 'order_66', 'letters, digits and underscore only'],
['/^[a-z]+$/', 'Order', 'lower-case letters only'],
['/colou?r/', 'color', 'the u is optional'],
['/^.{3}$/', 'PHP', 'exactly three of anything'],
];
foreach ($checks as [$pattern, $subject, $note]) {
printf("%-11s %-9s %d %s\n", $pattern, $subject, preg_match($pattern, $subject), $note);
}
Output:
Two rows do the teaching. {3,4} fails because 66 is only two digits, and [a-z] fails on Order because a range is literal and a capital O is not in it. Each class has an inverted twin written in capitals, so \D is any non-digit, and a leading caret inside brackets flips them too.
Anchors tie the pattern to the ends
By default a pattern matches anywhere inside the subject, which is why so much validation code quietly passes rubbish. ^ means the start of the string, $ means the end, and both together mean the whole string has to fit.
<?php
$subject = 'php regex tutorial';
var_dump(preg_match('/regex/', $subject)); // 1 — anywhere in the string
var_dump(preg_match('/^regex/', $subject)); // 0 — not at the start
var_dump(preg_match('/tutorial$/', $subject)); // 1 — at the end
// anchored at both ends means the whole string must fit the pattern
var_dump(preg_match('/^[a-z ]+$/', $subject)); // 1
var_dump(preg_match('/^[a-z ]+$/', 'php regex 8')); // 0 — the digit breaks it
// the gotcha: $ also matches just before a final newline
var_dump(preg_match('/^[a-z]+$/', "php\n")); // 1
var_dump(preg_match('/^[a-z]+$/D', "php\n")); // 0 — D makes $ mean the very end
Output:
The last pair is the one that bites. $ also matches immediately before a single trailing newline, so a pasted value ending in a stray line break still validates. The D modifier makes $ mean the very end and nothing else. Use it when you are checking a value rather than searching a document.
Capture groups and the $matches array
Wrap part of a pattern in round brackets and PHP keeps whatever that part matched. The pieces arrive in the third argument, which preg_match() fills by reference: you pass an undefined variable in and it comes back an array. $matches[0] is always the whole match and $matches[1] the first group, numbered by opening bracket, left to right.
<?php
$log = '2026-09-08 14:32:07 ERROR Database timeout';
// $matches is filled by reference — it is the third argument, not the return value
if (preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2})/', $log, $matches)) {
echo 'whole match: ', $matches[0], "\n";
echo 'year: ', $matches[1], "\n";
echo 'day: ', $matches[3], "\n";
}
// named groups survive you reordering the pattern later
preg_match('/^(?<date>\S+) (?<time>\S+) (?<level>\w+)/', $log, $m);
echo $m['level'], ' logged at ', $m['time'], "\n\n";
print_r($m);
Output:
Counting brackets stops being fun at about group four, which is what (?<name>...) fixes. The print_r shows what really happens: each named group is stored twice, under its name and under its number. Add a group at the front of a pattern and every number shifts; the names do not.
preg_match_all() when one match is not enough
preg_match() stops the moment it succeeds. preg_match_all() runs to the end of the subject and returns how many matches it made, so the return value is a count, not a flag. The array it fills is a grid: $matches[0] lists the whole matches, $matches[1] the first group from each one.
<?php
$text = 'Call 0300-1234567 or 0321-9876543 before Friday.';
$count = preg_match_all('/\d{4}-\d{7}/', $text, $matches);
echo "matches found: $count\n";
print_r($matches[0]);
// add a group and $matches[1] holds that group from every match
preg_match_all('/(\d{4})-(\d{7})/', $text, $m);
print_r($m[1]);
Output:
That shape is PREG_PATTERN_ORDER, the default. Pass PREG_SET_ORDER as the fourth argument and the array flips to one entry per match, each holding that match's groups — the shape you want before a foreach.
preg_replace() and the $1 backreference
preg_replace() takes a pattern, a replacement and a subject, and returns the rewritten string. It does not change the original. In the replacement, $1 means "whatever group one matched", so you can pull a string apart and rebuild it in a different order.
Loading comments...