Regular Expressions (preg)
Reviewed & published by Brayan K
A regular expression is a tiny pattern language for finding, extracting, and replacing text. PHP exposes it through the preg_* family. Learn the core syntax and four key functions, and validating an email or pulling every link from a page becomes a one-liner.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Test for a pattern with preg_match and read the result
- Use core syntax: \d \w \s, anchors, quantifiers, classes
- Pull pieces out with numbered and named capture groups
- Find every match with preg_match_all
- Find-and-replace with preg_replace and back-references
- Split strings on a pattern with preg_split
1️⃣ Testing with preg_match
preg_match($pattern, $subject, $matches) returns 1 if the pattern is found, 0 if not. The pattern is a string wrapped in delimiters (usually /.../). The building blocks: \d a digit, \w a word character, \s whitespace, + one-or-more, ^ start, $ end, \b word boundary. A trailing i makes it case-insensitive.
<?php
// preg_match tests whether a pattern is found in a string.
// Returns 1 (match), 0 (no match), or false (error).
// The pattern is a string wrapped in DELIMITERS — usually slashes /.../.
$text = 'Order #4821 confirmed';
if (preg_match('/\\d+/', $text, $m)) {
// \d = a digit, + = one or more.
// $m[0] holds the whole matched text.
echo "Found: " . $m[0] . "\n"; // Found: 4821
}
// \b = word boundary; the i flag at the end makes it case-insensitive.
var_dump(preg_match('/\\bphp\\b/i', 'I love PHP')); // int(1)
var_dump(preg_match('/^Order/', $text)); // int(1) ^ = start
var_dump(preg_match('/confirmed$/', $text)); // int(1) $ = end
?>2️⃣ Capture Groups
Wrap part of a pattern in parentheses ( ... ) to capture it — pull that piece out of the match. Captures land in the matches array: index 0 is the whole match, 1, 2, … are the groups left-to-right. Even better, name a group with (?<name>...) and read it as $m['name'] — far clearer than counting parentheses.
<?php
// CAPTURE GROUPS ( ... ) pull pieces OUT of a match.
// They appear in $m at index 1, 2, 3 ... (index 0 is the whole match).
$date = '2026-06-18';
preg_match('/(\\d{4})-(\\d{2})-(\\d{2})/', $date, $m);
echo "Whole: " . $m[0] . "\n"; // 2026-06-18
echo "Year: " . $m[1] . "\n"; // 2026
echo "Month: " . $m[2] . "\n"; // 06
echo "Day: " . $m[3] . "\n"; // 18
// NAMED groups (?<name>...) are clearer for real code.
preg_match('/(?<user>\\w+)@(?<host>\\w+\\.\\w+)/', 'ada@example.com', $e);
echo $e['user'] . " at " . $e['host'] . "\n"; // ada at example.com
?>3️⃣ Finding Every Match
preg_match_all doesn't stop at the first hit — it collects them all. Its results array is organised by group, so $m[1] is an array of every first-group capture. Note .*? below: the ? makes it lazy, matching as little as possible so each <a> tag is captured separately rather than one giant greedy span.
<?php
// preg_match_all finds EVERY match, not just the first.
$html = '<a>Home</a> <a>About</a> <a>Contact</a>';
// .*? is a LAZY match: grab as little as possible between the tags.
preg_match_all('/<a>(.*?)<\\/a>/', $html, $m);
// $m[0] = all full matches, $m[1] = all first-group captures.
echo "Count: " . count($m[1]) . "\n"; // Count: 3
echo implode(', ', $m[1]) . "\n"; // Home, About, Contact
?>4️⃣ Replacing & Splitting
preg_replace($pattern, $replacement, $subject) swaps every match. In the replacement string, $1, $2 … are back-references to your capture groups — perfect for reformatting. And preg_split breaks a string wherever the pattern matches, ideal for splitting on "any run of spaces or commas".
<?php
// preg_replace swaps every match for a replacement string.
$msg = 'Call 555-1234 or 555-9876 today';
// Replace each phone number with [redacted].
echo preg_replace('/\\d{3}-\\d{4}/', '[redacted]', $msg) . "\n";
// Use $1 in the replacement to reference a capture group (back-reference).
$name = 'Smith, John';
echo preg_replace('/(\\w+), (\\w+)/', '$2 $1', $name) . "\n"; // John Smith
// preg_split breaks a string on a pattern (here: any run of whitespace/commas).
$tags = preg_split('/[\\s,]+/', 'php, regex patterns');
echo implode('|', $tags) . "\n"; // php|regex|patterns
?>Now you try — fill in each ___ using the 👉 hint, then run it and check against the Output panel.
<?php
// 🎯 YOUR TURN — fill in each blank marked ___ , then run it.
// 1) Match a 5-digit US ZIP code. \d is a digit; {5} means exactly five.
$ok = preg_match('/^\\d___$/', '90210'); // 👉 the quantifier for "exactly 5"
echo $ok . "\n"; // should print: 1
// 2) Capture the area code from a phone number.
preg_match('/\\((\\d{3})\\)/', '(415) 555-0000', $m);
echo $m[___] . "\n"; // 👉 the group index for the captured digits
// should print: 415
// ✅ Expected output:
// 1
// 415
?>🧩 Reorder Challenge
These lines extract and print the year from a date — but they're scrambled. Put them in the order that prints 2026.
B → D → C → A
Set the subject string (B), optionally initialise $m (D), run preg_match which fills $m (C), then echo the first capture group (A). You can't read $m[1] before the match populates it.
🧠 Quick Recall — Predict the Output
0 — the anchors ^ and $ require the entire string to be digits, but '12a' ends in a letter, so there's no match.
2. What does this output?
r*g*x — every vowel in the character class [aeiou] is replaced with an asterisk.
3. How many elements are in $m[0]?
3 — there are three separate runs of digits (1, 22, 333), so preg_match_all finds three matches.
Common Errors (and the fix)
- "Warning: No ending delimiter found" — you forgot the closing slash, or your pattern contains an unescaped /. Escape it as \/ or use a different delimiter like #...#.
- A greedy .* matches too much — it grabs everything up to the last possible point. Make it lazy with .*?.
- Your special character is treated literally (or vice versa) — escape literals like . as \.; an unescaped dot matches any character.
- UTF-8 / accented text behaves oddly — add the u flag so the pattern is treated as Unicode: /.../u.
Pro Tips
- 💡 Prototype on regex101.com (PCRE2 flavour). Its live explainer turns cryptic patterns into plain English.
- 💡 Prefer named groups. $m['year'] beats $m[1] — readable and refactor-proof.
- 💡 Don't regex what a plain function can do. str_contains, explode and str_replace are faster and clearer for fixed strings.
📋 Quick Reference — preg & Syntax
| Token / Function | Example | Meaning |
|---|---|---|
| \d \w \s | \d+ | Digit / word char / whitespace |
| ^ $ \b | ^abc$ | Start / end / word boundary |
| + * ? {n} | \d{3} | Quantifiers (count) |
| ( ) (?<name>) | (?<y>\d{4}) | Capture group |
| preg_match | preg_match($p, $s, $m) | First match (0/1) |
| preg_match_all | preg_match_all($p, $s, $m) | Every match |
| preg_replace | preg_replace($p, $r, $s) | Find & replace |
| preg_split | preg_split($p, $s) | Split into array |
Mini-Challenge: Slug Maker
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments.
<?php
// 🎯 MINI-CHALLENGE: a simple slug maker.
// No code is filled in — work from the steps below, then run it.
//
// 1. Start with $title = 'Hello, World! PHP 8 Rocks'.
// 2. strtolower() it.
// 3. Use preg_replace to turn any run of NON-alphanumeric characters
// into a single dash: pattern /[^a-z0-9]+/ replacement '-'
// 4. Use preg_replace again (or trim) to strip a leading/trailing dash:
// pattern /^-|-$/ replacement ''
// 5. echo the result.
//
// ✅ Expected output:
// hello-world-php-8-rocks
// your code here
?>🎉 Lesson Complete!
- ✅ Patterns live between delimiters (/.../) with flags after the close
- ✅ \d \w \s, anchors, quantifiers and classes describe the shape of text
- ✅ preg_match tests + captures; preg_match_all finds them all
- ✅ Named groups (?<name>...) are clearer than numbered ones
- ✅ preg_replace (with $1 back-refs) and preg_split transform text
Practice quiz
What does echo preg_match('/^\d+$/', '12a'); print?
- 1
- false
- 0
- 3
Answer: 0. The ^ and $ anchors require the whole string to be digits, but '12a' ends in a letter, so there is no match (0).
What does echo preg_replace('/[aeiou]/', '*', 'regex'); print?
- r*g*x
- *****
- regex
- rgx
Answer: r*g*x. Every vowel in the character class [aeiou] is replaced with an asterisk: r*g*x.
After preg_match_all('/\d+/', 'a1b22c333', $m); what is count($m[0])?
- 6
- 1
- 0
- 3
Answer: 3. There are three separate runs of digits (1, 22, 333), so preg_match_all finds three matches.
In a capture-group match, what does index 0 of the matches array hold?
- The first capture group
- The whole matched text
- The last capture group
- Always null
Answer: The whole matched text. $m[0] is the entire match; $m[1], $m[2], ... are the capture groups left to right.
What does the i flag at the end of a pattern like /php/i do?
- Makes matching case-insensitive
- Treats the pattern as Unicode
- Makes . match newlines
- Anchors to the line start
Answer: Makes matching case-insensitive. The i modifier makes the pattern case-insensitive, so /php/i matches 'PHP'.
What makes .*? different from .* in a pattern?
- The ? makes it match more
- The ? makes it case-insensitive
- The ? makes it lazy — matches as little as possible
- It is a syntax error
Answer: The ? makes it lazy — matches as little as possible. Appending ? makes a quantifier lazy, grabbing as little as possible instead of being greedy.
What does preg_replace('/(\w+), (\w+)/', '$2 $1', 'Smith, John') return?
- Smith John
- John Smith
- Smith, John
- $2 $1
Answer: John Smith. $1 and $2 are back-references to the capture groups, so the names are swapped to 'John Smith'.
Which function should you use to split a string on a pattern?
- preg_match
- preg_replace
- preg_match_all
- preg_split
Answer: preg_split. preg_split breaks a string wherever the pattern matches and returns the pieces as an array.
Why prefer a named group like (?<year>\d{4}) over a numbered one?
- It matches faster
- It is self-documenting and survives reordering
- It allows more characters
- Numbered groups are not supported in PHP
Answer: It is self-documenting and survives reordering. Named groups read clearly ($m['year']) and don't break when you insert or reorder groups.
When should you NOT use a regular expression?
- To validate a fixed pattern shape
- To extract a list of matches
- To parse structured formats like HTML, XML, or JSON
- To find and replace with variation
Answer: To parse structured formats like HTML, XML, or JSON. Use a real parser (DOMDocument, json_decode) for structured formats; nesting and edge cases defeat regex.
Continue this course
- Previous: Closures & Arrow Functions
- Next: Generators (yield) — Produce values lazily with yield to process large data sets efficiently
- Quick reference: PHP cheat sheet › Strings
Frequently asked questions
What are the slashes in a PHP regex like '/\d+/' for?
Those are delimiters — they mark where the pattern begins and ends, and everything after the closing delimiter is for flags. Slashes are conventional, but any non-alphanumeric character works, which is handy when your pattern itself contains slashes: matching a URL is cleaner with '#https?://#' than with escaped slashes. The trailing letters (like the i in '/php/i') are pattern modifiers: i for case-insensitive, m for multiline, s for dot-matches-newline, u for UTF-8.
What's the difference between preg_match and preg_match_all?
preg_match stops at the first match and fills the matches array with that single result (full match in [0], capture groups in [1], [2], ...). preg_match_all keeps going and finds every match in the string; its matches array is organised by group, so $m[0] is an array of all full matches and $m[1] is an array of every first-group capture. Use preg_match to test or grab one thing; use preg_match_all to extract a list, like every link in some HTML.
Why should I use named capture groups?
Numbered groups ($m[1], $m[2]) break the moment you insert or reorder a group, and they're hard to read. A named group (?<year>\d{4}) lets you write $m['year'], which is self-documenting and survives refactoring. The pattern is slightly longer, but for anything beyond a throwaway script the clarity is well worth it — future-you will know exactly what each captured piece means.
When should I NOT use a regular expression?
Don't parse structured formats like HTML, XML, or JSON with regex — use a real parser (DOMDocument, json_decode), because nesting and edge cases will defeat any pattern you write. For simple fixed-string work, plain functions are faster and clearer: str_contains, str_replace, explode, and str_starts_with handle 'does it contain', 'swap this text', and 'split on a comma' without a pattern at all. Reach for preg_* when you genuinely need flexible pattern matching — validation, extraction, or find-and-replace with variation.