📝 Web Development

PHP Regular Expressions: preg_match and preg_replace

Sep 11, 20265 min read1 viewsBy Flow

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:

php regex output showing int 1 for a match, int 0 for no match and int 1 for the hash delimiter

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:

php regex output showing eight patterns tested against short strings with a 1 or 0 beside each

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:

php regex output showing seven int results where the anchored patterns return 0 for partial matches

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:

php regex output showing a parsed log line with the year and day printed and a matches array holding named and numbered keys

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:

php regex output showing matches found 2 above two arrays of phone numbers and their leading four digits

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.

<?php
$text = 'Posted on 08/09/2026 and updated on 15/09/2026.';

// $1 $2 $3 in the replacement are the groups from the pattern
echo preg_replace('#(\d{2})/(\d{2})/(\d{4})#', '$3-$2-$1', $text), "\n";

// collapse any run of whitespace into one space
echo preg_replace('/\s+/', ' ', "too    many\n\nspaces"), "\n";

// build a URL slug: anything that is not a letter or digit becomes a dash
echo preg_replace('/[^a-z0-9]+/i', '-', 'PHP Regex: preg_match & preg_replace'), "\n";

Output:

php regex output showing dates rewritten to year first, collapsed whitespace and a dashed slug

The first pattern uses # so the slashes in the date need no escaping. The third line is honest about what it did: the underscores went too, because an underscore is not a letter or a digit. When the replacement needs a decision rather than a rearrangement, preg_replace_callback() hands each match to a function.

preg_split() splits on a pattern

explode() needs one fixed separator. Real input rarely obliges: a pasted list arrives separated by commas, semicolons, pipes and random spacing, all in one line. preg_split() takes a pattern instead, so one call covers the lot.

<?php
$line = 'apples,  bananas ; cherries|dates';

// one pattern, three different separators, spaces eaten on both sides
print_r(preg_split('/\s*[,;|]\s*/', $line));

// explode() takes a fixed string, so it only sees the commas
print_r(explode(',', $line));

// -1 means no limit; the flag drops the empty piece the double comma creates
print_r(preg_split('/\s*,\s*/', 'a, b,, c', -1, PREG_SPLIT_NO_EMPTY));

Output:

php regex output showing a clean four item array beside the two item array explode produced from the same line

The middle array is explode() on the same string, and it shows the difference: it knows only the comma, so the semicolon and the pipe survive inside one messy piece. The -1 in the last call is the limit argument, meaning no limit, and it is there only because the flags come after it.

The three modifiers worth knowing: i, m and u

Modifiers are letters after the closing delimiter. i makes matching case-insensitive. m is multiline: it moves ^ and $ from the ends of the string to the ends of each line. u tells the engine the subject is UTF-8.

<?php
$subject = "PHP is fun\nRegex is not";

var_dump(preg_match('/php/', $subject));    // 0 — case matters by default
var_dump(preg_match('/php/i', $subject));   // 1 — i ignores case

var_dump(preg_match_all('/^\w+/', $subject));    // 1 — ^ is the start of the string
var_dump(preg_match_all('/^\w+/m', $subject));   // 2 — m makes ^ the start of a line

// without u, PHP counts bytes; é is two of them
var_dump(preg_match('/^.{4}$/', 'café'));    // 0
var_dump(preg_match('/^.{5}$/', 'café'));    // 1 — five bytes
var_dump(preg_match('/^.{4}$/u', 'café'));   // 1 — four characters
var_dump(preg_match('/^\w+$/', 'café'));     // 0 — é is not a word character
var_dump(preg_match('/^\w+$/u', 'café'));    // 1 — with u, it is

Output:

php regex output showing nine int results where the u modifier changes the answer for an accented word

The last four lines are why u is not optional. Without it a pattern counts bytes, so the four-character word café measures five and \w refuses the accent. Any text that is not plain English — Urdu, Arabic, Hindi, Chinese, or a French name typed into an English form — needs u on every pattern that touches it, or the engine can cut a character in half.

A PHP regex cannot parse HTML

This is the honest warning in the lesson. HTML nests, and a pattern has no memory of what it is inside — no idea it is in a comment, an attribute or a script block. Markup-scraping patterns work on the sample you tested and fail on the real page.

<?php
$html = '<a href="/one">One</a> <!-- <a href="/two">Two</a> --> <a
   href="/three">Three</a>';

// the regex way: looks right, and is wrong twice over
preg_match_all('/<a href="([^"]*)">/', $html, $m);
print_r($m[1]);

// the parser way
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML($html);

foreach ($doc->getElementsByTagName('a') as $link) {
    echo $link->getAttribute('href'), "\n";
}

Output:

php regex output showing the pattern returning one wrong link while DOMDocument returns the two correct hrefs

The pattern returned two links and got the set wrong twice over: it counted /two, which is commented out and is not a link at all, and it missed /three, which is real but has its attribute on the next line. Only /one is right, and that is luck rather than skill. DOMDocument reads the same markup and answers /one and /three. Use a parser for markup, and keep the pattern for the text you pull out of it.

Three mistakes with PHP regex

Forgetting the delimiters. preg_match('\d+', $text) looks fine and returns false with a "No ending delimiter" warning, because the backslash was read as the delimiter and never closed. false is falsy, so if (preg_match(...)) treats a broken pattern exactly like "no match" and the bug hides.

Validating an email with a pattern. Every hand-rolled email regex rejects addresses that are legal and accepts ones that are not, because the real grammar is far bigger than the pattern people paste. PHP ships the answer: filter_var($email, FILTER_VALIDATE_EMAIL) returns the address or false, in one line, maintained for you. Then send a confirmation link, because that is the only real check.

Letting a greedy quantifier run. * and + take as much as they can and hand characters back only when the rest of the pattern fails. Run /".*"/ over "first" and "second" and you get the whole line, because .* ran to the last quote. Add a question mark — /".*?"/ — and it turns lazy, stops at the first quote it can, and returns "first".

Common questions about PHP regex

What is the difference between preg_match and preg_match_all?

preg_match() stops at the first match and returns 1, 0 or false. preg_match_all() finds every match and returns how many there were. Use the first to test or grab one thing, the second to collect a list.

Why does my PHP regex return false instead of 0?

0 means the pattern ran and found nothing. false means it never ran: a missing delimiter, an unbalanced bracket, or a subject the engine gave up backtracking through. preg_last_error_msg() names which — a runaway pattern reports "Backtrack limit exhausted".

How do I make a PHP regex case-insensitive?

Add i after the closing delimiter: '/php/i'. It applies to the whole pattern. For anything beyond plain ASCII add u as well, so '/café/iu', or the accents are compared as bytes.

How do I match a literal dot or question mark?

Escape it with a backslash, so '/\./' matches a full stop, or put it in a character class where most characters lose their meaning. If the text comes from a variable, run it through preg_quote($text, '/') first.

Is a PHP regex slower than a string function?

Yes, and that is nearly always fine. strpos() beats a pattern for finding a fixed word, so use it when the target is fixed. Reach for a pattern when the target has a shape, and worry about the cost only when a profiler puts a preg_ call at the top of the list.

What is next

The strings you most want to test with a PHP regex are the ones a visitor typed. PHP forms is where they arrive: $_POST, $_GET, validating what comes back, and redisplaying a half-filled form without handing an attacker a script tag.

Comments

Loading comments...

Link copied to clipboard