Regex #
Regex (Regular Expression) is a mini-language for describing text patterns — something far more expressive than plain string search, but also far easier to get wrong if not understood well. PHP uses the PCRE (Perl Compatible Regular Expressions) library, which is one of the most feature-rich regex implementations — supporting capturing groups, named groups, lookahead, lookbehind, and much more. Mastering regex in PHP isn’t about memorizing all its syntax — it’s about understanding the patterns that appear most often: validating emails and phone numbers, extracting data from HTML or free text, text replacement with logic, and splitting strings with complex conditions. This article covers all of that from the basics to the advanced features genuinely useful in real code.
Anatomy of a PHP Regex Pattern #
PHP regex patterns are always wrapped in a delimiter — a boundary character marking the start and end of the pattern. The most common delimiter is /, but other characters like #, ~, or @ are also valid — useful when the pattern contains many / so you don’t need to escape them.
/pattern/modifier
#pattern#modifier
~pattern~i
<?php
// / delimiter
preg_match('/hello/', 'hello world');
// # delimiter — useful when the pattern contains many /
preg_match('#https?://[^/]+/#', 'https://example.com/path/');
// ~ delimiter — for patterns containing # (comments)
preg_match('~\d+~', 'there are 42 numbers');
Basic Pattern Elements #
Literal Characters and Meta-characters #
Ordinary characters match themselves: a b c 1 2 3
Meta-characters have special meaning: . ^ $ * + ? { } [ ] \ | ( )
To match a meta-character literally, escape it with \: \. \* \+ \?
<?php
// Literal characters
preg_match('/php/', 'learning php'); // matches
// Dot (.) — matches ANY single character except newline
preg_match('/p.p/', 'php'); // matches — p + anything + p
preg_match('/p.p/', 'pap'); // matches
preg_match('/p.p/', 'pp'); // no match — needs one character in the middle
// Escaping meta-characters
preg_match('/3\.14/', '3.14'); // matches — literal dot
preg_match('/3\.14/', '3X14'); // no match
Character Classes [...]
#
A character class matches exactly one character from the defined set:
<?php
// [abc] — matches 'a', 'b', or 'c'
preg_match('/[aeiou]/', 'hello'); // matches — 'e' is a vowel
// [a-z] — character ranges
preg_match('/[a-z]/', 'Hello'); // matches — 'e', 'l', 'l', 'o'
preg_match('/[A-Z]/', 'Hello'); // matches — 'H'
preg_match('/[0-9]/', 'abc123'); // matches — '1'
preg_match('/[a-zA-Z0-9]/', 'test'); // matches — alphanumeric
// [^...] — negation: characters NOT in the set
preg_match('/[^0-9]/', '123abc'); // matches — 'a' isn't a digit
preg_match('/[^a-z]/', 'ABC'); // matches — 'A' isn't lowercase
Shorthand Character Classes #
| Shorthand | Equivalent to | Description |
|---|---|---|
\d | [0-9] | Digit |
\D | [^0-9] | Non-digit |
\w | [a-zA-Z0-9_] | Word character (alphanumeric + underscore) |
\W | [^a-zA-Z0-9_] | Non-word character |
\s | [ \t\n\r\f\v] | Whitespace |
\S | [^ \t\n\r\f\v] | Non-whitespace |
\b | — | Word boundary (a position, not a character) |
\B | — | Non-word boundary |
<?php
// \d — digit
preg_match_all('/\d+/', 'there are 12 apples and 34 mangoes', $m);
// ['12', '34']
// \w — word character
preg_match_all('/\w+/', 'hello world', $m);
// ['hello', 'world']
// \s — whitespace (space, tab, newline)
$clean = preg_replace('/\s+/', ' ', "there are extra spaces");
// "there are extra spaces"
// \b — word boundary
preg_match_all('/\bcat\b/', 'the cat scattered cats', $m);
// ['cat'] — only the standalone 'cat', not the 'cat' in 'scattered' or 'cats'
preg_match_all('/cat/', 'the cat scattered cats', $m);
// ['cat', 'cat', 'cat'] — without a word boundary, every 'cat' matches
Anchors — Positions, Not Characters #
Anchors don’t match characters, but positions within the string:
<?php
// ^ — start of string (or start of line with the m modifier)
preg_match('/^PHP/', 'PHP is a language'); // matches
preg_match('/^PHP/', 'Learning PHP'); // no match — PHP isn't at the start
// $ — end of string (or end of line with the m modifier)
preg_match('/php$/', 'learning php'); // matches
preg_match('/php$/', 'php is a language'); // no match — php isn't at the end
// ^ and $ together — match the entire string
preg_match('/^\d{5}$/', '12345'); // matches — exactly 5 digits
preg_match('/^\d{5}$/', '123456'); // no match — 6 digits
preg_match('/^\d{5}$/', 'abc12'); // no match — contains letters
// \A — absolute start of string (unaffected by the m modifier)
// \Z — absolute end of string before an optional newline
// \z — absolute end of string
preg_match('/\Aphp\z/i', 'PHP'); // matches
Quantifiers — How Many Times #
Quantifiers determine how many times the preceding element must appear:
| Quantifier | Meaning |
|---|---|
* | 0 or more (greedy) |
+ | 1 or more (greedy) |
? | 0 or 1 (greedy) |
{n} | Exactly n times |
{n,} | At least n times |
{n,m} | Between n and m times |
*? | 0 or more (lazy) |
+? | 1 or more (lazy) |
?? | 0 or 1 (lazy) |
<?php
$html = '<b>bold</b> and <b>bold again</b>';
// Greedy — matches as much as possible
preg_match('/<b>.*<\/b>/', $html, $m);
echo $m[0]; // '<b>bold</b> and <b>bold again</b>' — too much!
// Lazy (*?) — matches as little as possible
preg_match('/<b>.*?<\/b>/', $html, $m);
echo $m[0]; // '<b>bold</b>' — only the first block
// preg_match_all with lazy
preg_match_all('/<b>.*?<\/b>/', $html, $m);
// ['<b>bold</b>', '<b>bold again</b>']
// Concrete quantifiers
preg_match('/\d{4}/', '2024'); // exactly 4 digits
preg_match('/\d{2,4}/', '123'); // 2-4 digits
preg_match('/\d{3,}/', '12345'); // at least 3 digits
// ? for optional
preg_match('/colou?r/', 'color'); // matches — u is optional
preg_match('/colou?r/', 'colour'); // matches — u is present
Capturing Groups and Named Groups #
Parentheses () create a capturing group — a portion of the pattern whose result is stored separately and can be referenced.
<?php
$date = '2024-03-15';
// Numeric groups — referenced with $1, $2, $3 or \1, \2, \3
preg_match('/(\d{4})-(\d{2})-(\d{2})/', $date, $m);
echo $m[0]; // '2024-03-15' — the whole match
echo $m[1]; // '2024' — group 1 (year)
echo $m[2]; // '03' — group 2 (month)
echo $m[3]; // '15' — group 3 (day)
// Named groups — more readable: (?P<name>pattern)
preg_match('/(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})/', $date, $m);
echo $m['year']; // '2024'
echo $m['month']; // '03'
echo $m['day']; // '15'
// Use named groups in replacements
$result = preg_replace(
'/(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})/',
'${day}/${month}/${year}',
$date
);
echo $result; // '15/03/2024'
// Non-capturing groups (?:...) — group without storing
// Useful for grouping + quantifiers without using a capture slot
preg_match('/(?:https?|ftp):\/\/(\S+)/', 'https://example.com', $m);
echo $m[0]; // 'https://example.com'
echo $m[1]; // 'example.com' — group 1 is only the domain, not the protocol
Lookahead and Lookbehind #
Lookahead and lookbehind match a position based on what’s around it, without consuming characters:
<?php
// Positive lookahead (?=...) — matches if followed by a pattern
// Find numbers followed by " km"
preg_match_all('/\d+(?= km)/', '100 km and 50 miles', $m);
// ['100'] — only '100' is followed by ' km', not '50'
// Negative lookahead (?!...) — matches if NOT followed by a pattern
preg_match_all('/\d+(?! km)/', '100 km and 50 miles', $m);
// ['50'] — '50' isn't followed by ' km'
// Positive lookbehind (?<=...) — matches if preceded by a pattern
// Find numbers preceded by "Rp "
preg_match_all('/(?<=Rp )\d+/', 'price Rp 15000 and Rp 25000', $m);
// ['15000', '25000']
// Negative lookbehind (?<!...) — matches if NOT preceded by a pattern
preg_match_all('/\d+(?! km)/', '100 km and 50 miles', $m);
// Practical example: password validation with lookahead
function validatePassword(string $password): bool
{
// At least 8 characters
// Contains at least 1 uppercase letter
// Contains at least 1 digit
// Contains at least 1 special character
return (bool) preg_match(
'/^(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/',
$password
);
}
var_dump(validatePassword('weakpass')); // false — doesn't meet the requirements
var_dump(validatePassword('Strong123!')); // true — all requirements met
var_dump(validatePassword('NoDigits!')); // false — no digit
Modifiers #
Modifiers change how the pattern works as a whole:
| Modifier | Description |
|---|---|
i | Case-insensitive — [a-z] also matches [A-Z] |
m | Multiline — ^ and $ match the start/end of every line, not just the string |
s | Dotall — . also matches newlines (\n) |
x | Extended — whitespace and #comments are ignored, patterns can be multiline |
u | Unicode — pattern and string are treated as UTF-8 |
g | Global — doesn’t exist in PHP; use preg_match_all() |
<?php
$text = "Hello World\nLearning PHP";
// i — case-insensitive
preg_match('/php/i', $text, $m); // matches — PHP
// m — multiline
preg_match_all('/^\w+/m', $text, $m);
// ['Hello', 'Learning'] — start of every line
// s — dotall
preg_match('/Hello.*PHP/s', $text, $m);
// matches — . crosses the newline
// x — extended (patterns with comments for readability)
$emailPattern = '/
^ # Start of string
[a-zA-Z0-9._%+-]+ # Username
@ # At-sign
[a-zA-Z0-9.-]+ # Domain name
\. # Dot
[a-zA-Z]{2,} # TLD
$ # End of string
/x';
preg_match($emailPattern, '[email protected]'); // matches
// u — Unicode/UTF-8
preg_match('/\p{L}+/u', 'héllo wörld', $m); // \p{L} = Unicode letter
// matches Unicode characters
// Combine several modifiers
preg_match('/php/im', $text); // case-insensitive + multiline
PHP PCRE Functions #
preg_match() — Find the First Match
#
<?php
$pattern = '/(\d{4})-(\d{2})-(\d{2})/';
$string = 'Date: 2024-03-15, deadline: 2024-04-30';
// Returns 1 if matched, 0 if not, false on error
$result = preg_match($pattern, $string, $matches, PREG_OFFSET_CAPTURE);
// $matches[0] — the whole match (with position if PREG_OFFSET_CAPTURE)
// $matches[1] — group 1, etc.
var_dump($result); // int(1)
print_r($matches);
// [0] => ['2024-03-15', 9] — value and byte position
// [1] => ['2024', 9]
// [2] => ['03', 14]
// [3] => ['15', 17]
// Without PREG_OFFSET_CAPTURE
preg_match($pattern, $string, $matches);
echo $matches[1]; // '2024'
preg_match_all() — Find All Matches
#
<?php
$html = '<a href="https://google.com">Google</a> and <a href="https://php.net">PHP</a>';
// Extract all hrefs and link texts
preg_match_all('/<a href="([^"]+)">([^<]+)<\/a>/', $html, $matches);
echo $matches[0][0]; // '<a href="https://google.com">Google</a>'
echo $matches[1][0]; // 'https://google.com' — URL
echo $matches[2][0]; // 'Google' — text
echo $matches[1][1]; // 'https://php.net'
echo $matches[2][1]; // 'PHP'
// PREG_SET_ORDER format — more intuitive to iterate
preg_match_all('/<a href="([^"]+)">([^<]+)<\/a>/', $html, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
echo "URL: {$match[1]}, Text: {$match[2]}\n";
}
// URL: https://google.com, Text: Google
// URL: https://php.net, Text: PHP
preg_replace() — Replace with a Pattern
#
<?php
// Simple replacement
$text = "price: 15000 rupiah and 25000 rupiah";
$result = preg_replace('/\d+/', 'N', $text);
// "price: N rupiah and N rupiah"
// Use backreferences in the replacement
$date = '15/03/2024';
$iso = preg_replace('/(\d{2})\/(\d{2})\/(\d{4})/', '$3-$2-$1', $date);
// '2024-03-15'
// Add thousands separators to all numbers
$sentence = "buy 1000 apples and 2500 mangoes";
$result = preg_replace('/\d+/', fn($m) => number_format((int)$m[0], 0, ',', '.'), $sentence);
// Can't be done directly — use preg_replace_callback for this
// Limit the number of replacements
$text = "cat sat on the mat with a cat";
$result = preg_replace('/cat/', 'dog', $text, 1); // replace only the first one
// "dog sat on the mat with a cat"
preg_replace_callback() — Replace with Logic
#
When the replacement needs logic (not just a static string), use preg_replace_callback():
<?php
// Convert all numbers to rupiah format
$text = "laptop priced 15000000 and mouse 250000";
$result = preg_replace_callback('/\d+/', function(array $m): string {
return 'Rp ' . number_format((int)$m[0], 0, ',', '.');
}, $text);
// "laptop priced Rp 15.000.000 and mouse Rp 250.000"
// Convert markdown **bold** to <strong>
$markdown = "this is **bold** and **very bold** too";
$html = preg_replace_callback('/\*\*([^*]+)\*\*/', function(array $m): string {
return '<strong>' . htmlspecialchars($m[1]) . '</strong>';
}, $markdown);
// "this is <strong>bold</strong> and <strong>very bold</strong> too"
// With arrow functions (PHP 7.4+)
$slug = preg_replace_callback('/[A-Z]/', fn($m) => '-' . strtolower($m[0]), 'camelCaseString');
$slug = ltrim($slug, '-');
// "camel-case-string"
preg_split() — Split with a Pattern
#
<?php
// Split with several delimiters at once
$csv = "apple, mango; orange|grape, watermelon";
$fruits = preg_split('/[\s,;|]+/', $csv, -1, PREG_SPLIT_NO_EMPTY);
// ['apple', 'mango', 'orange', 'grape', 'watermelon']
// PREG_SPLIT_DELIM_CAPTURE — include delimiters in the result
$sentence = "hello! world? this is php.";
$parts = preg_split('/([!?.])/', $sentence, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
// ['hello', '!', ' world', '?', ' this is php', '.']
// Split at word boundaries
$text = "CamelCaseVariableName";
$words = preg_split('/(?=[A-Z])/', $text, -1, PREG_SPLIT_NO_EMPTY);
// ['Camel', 'Case', 'Variable', 'Name']
preg_grep() — Filter an Array
#
<?php
// Return array elements matching a pattern
$data = ['apple', 'apricot', 'banana', 'avocado', 'blueberry'];
$startsWithA = preg_grep('/^a/i', $data);
// [0=>'apple', 1=>'apricot', 3=>'avocado']
// Invert — elements that DON'T match
$notStartsWithA = preg_grep('/^a/i', $data, PREG_GREP_INVERT);
// [2=>'banana', 4=>'blueberry']
// Validate an array of phone numbers
$numbers = ['08123456789', '0812-3456-789', '+628****4567', 'not a number', '021-1234567'];
$valid = preg_grep('/^(\+62|0)[0-9\-]{9,13}$/', $numbers);
// ['08123456789', '0812-3456-789', '+628****4567', '021-1234567']
Real-World Regex Patterns Frequently Used #
Common Input Validation #
<?php
// Email — simple but practical enough
function validateEmail(string $email): bool
{
return (bool) preg_match('/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/', $email);
}
// Note: for truly accurate email validation, use filter_var()
// filter_var($email, FILTER_VALIDATE_EMAIL)
// Indonesian phone number
function validateIndonesianPhone(string $number): bool
{
// Format: 08xxxxxxxxx or +628xxxxxxxxx, 10-14 digits long
return (bool) preg_match('/^(\+62|62|0)8[1-9][0-9]{7,10}$/', preg_replace('/[\s\-]/', '', $number));
}
// Indonesian postal code (5 digits)
function validatePostalCode(string $code): bool
{
return (bool) preg_match('/^\d{5}$/', $code);
}
// NIK (Indonesian ID number — 16 digits)
function validateNIK(string $nik): bool
{
return (bool) preg_match('/^\d{16}$/', $nik);
}
// URL
function validateUrl(string $url): bool
{
return (bool) preg_match('/^https?:\/\/[^\s\/$.?#].[^\s]*$/', $url);
}
// Hex color (#RGB or #RRGGBB)
function validateHex(string $color): bool
{
return (bool) preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $color);
}
// URL slug (lowercase letters, numbers, and hyphens)
function validateSlug(string $slug): bool
{
return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug);
}
Data Extraction #
<?php
// Extract all emails from text
function extractEmails(string $text): array
{
preg_match_all('/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/', $text, $m);
return array_unique($m[0]);
}
// Extract all URLs from text
function extractUrls(string $text): array
{
preg_match_all('/https?:\/\/[^\s<>"\']+/', $text, $m);
return array_unique($m[0]);
}
// Extract hashtags from social media text
function extractHashtags(string $text): array
{
preg_match_all('/#(\w+)/', $text, $m);
return $m[1]; // only the word after #, not the # itself
}
// Extract all numbers from text
function extractNumbers(string $text): array
{
preg_match_all('/\d+(?:[.,]\d+)?/', $text, $m);
return $m[0];
}
// Extract date components
function parseDate(string $text): ?array
{
if (!preg_match('/(?P<day>\d{1,2})[\/\-.] (?P<month>\d{1,2})[\/\-.] (?P<year>\d{4})/', $text, $m)) {
return null;
}
return ['day' => $m['day'], 'month' => $m['month'], 'year' => $m['year']];
}
Text Transformations #
<?php
// camelCase to snake_case
function camelToSnake(string $input): string
{
$pattern = '/(?<!^)(?=[A-Z])/';
$snake = preg_replace($pattern, '_', $input);
return strtolower($snake);
}
echo camelToSnake('camelCaseString'); // camel_case_string
echo camelToSnake('getUserById'); // get_user_by_id
// snake_case to camelCase
function snakeToCamel(string $input): string
{
return lcfirst(preg_replace_callback('/_([a-z])/', fn($m) => strtoupper($m[1]), $input));
}
echo snakeToCamel('snake_case_string'); // snakeCaseString
// Clean up excessive whitespace
function cleanSpaces(string $input): string
{
return trim(preg_replace('/\s+/', ' ', $input));
}
echo cleanSpaces(" there are extra spaces "); // "there are extra spaces"
// Highlight keywords in text
function highlight(string $text, string $keyword): string
{
$pattern = '/(' . preg_quote($keyword, '/') . ')/i';
return preg_replace($pattern, '<mark>$1</mark>', $text);
}
echo highlight('Learning PHP is fun', 'php');
// 'Learning <mark>PHP</mark> is fun'
// Sanitize input: remove characters other than letters, numbers, spaces
function sanitize(string $input): string
{
return preg_replace('/[^a-zA-Z0-9\s]/', '', $input);
}
preg_quote() — Escaping User Input
#
When a regex pattern contains data from user input, always escape it first with preg_quote():
<?php
$searchTerm = $_GET['q'] ?? '';
// ANTI-PATTERN: using user input directly in a pattern — very dangerous!
// If the user inputs "a(b", the pattern becomes /a(b/ which is invalid → error!
preg_match("/$searchTerm/i", $text);
// CORRECT: escape first with preg_quote()
$escaped = preg_quote($searchTerm, '/'); // second argument = delimiter
$searchResult = preg_match("/$escaped/i", $text);
// For highlighting search results
function searchAndHighlight(string $text, string $term): string
{
if (empty($term)) return htmlspecialchars($text);
$escaped = preg_quote($term, '~');
return preg_replace_callback(
"~($escaped)~i",
fn($m) => '<mark>' . htmlspecialchars($m[1]) . '</mark>',
htmlspecialchars($text)
);
}
When to Use Regex and When Not To #
Use Regex when:
✓ Complex patterns involving variation or optional parts
✓ Parsing text with semi-regular structure
✓ Validating formats beyond just substring presence
✓ Extracting specific parts of a string with capturing groups
Avoid Regex when:
✗ Simple substring search — use str_contains(), strpos()
✗ Checking prefixes/suffixes — use str_starts_with(), str_ends_with()
✗ Parsing HTML/XML — use DOMDocument or SimpleXML
✗ Parsing JSON — use json_decode()
✗ Parsing CSV — use str_getcsv() or fgetcsv()
<?php
// ANTI-PATTERN: regex for simple string operations
if (preg_match('/^hello/', $str)) { } // overkill
if (preg_match('/world$/', $str)) { } // overkill
if (preg_match('/foo/', $str)) { } // overkill
// CORRECT: built-in string functions are more efficient
if (str_starts_with($str, 'hello')) { } // PHP 8.0+
if (str_ends_with($str, 'world')) { } // PHP 8.0+
if (str_contains($str, 'foo')) { } // PHP 8.0+
Summary #
- PHP regex delimiters can be
/,#,~, or other characters — pick one that doesn’t appear in the pattern to avoid repeated escaping.- Character classes
[abc]match one character from the set;[^abc]is the opposite. Shorthands:\d(digit),\w(word char),\s(whitespace),\b(word boundary).- Greedy vs lazy quantifiers —
.*matches as much as possible;.*?matches as little as possible. Lazy is almost always more appropriate for HTML-like parsing.- Named groups
(?P<name>pattern)make results more readable than$m[1],$m[2]. Use${name}or\k<name>in replacements.- Lookahead/lookbehind match positions based on context without consuming characters — very useful for password validation and context-dependent data extraction.
preg_replace_callback()for replacements needing PHP logic (number formatting, case conversion, etc.) — more powerful thanpreg_replace()with backreferences alone.preg_quote()is mandatory before inserting user input into a regex pattern — without it, characters like(,.,*can make the pattern invalid or enable ReDoS attacks.- For parsing HTML, XML, JSON, CSV — don’t use regex; use the right parser (DOMDocument, json_decode, str_getcsv). Regex can’t reliably parse recursive languages.