Why preg_match Returns 1, 0, or false — and Why Checking for false Matters
A signup form accepts a username, and one call in your validation layer decides whether it is acceptable. The instinct is if (preg_match($pattern, $input)) — and it works right up to the day the pattern contains a typo, or the input trips a backtrack limit, and preg_match returns false. false is falsy, so your validation rejects every input and the bug report reads "the form is broken for everyone."
preg_match() has exactly three outcomes:
| Return | Meaning | Typical cause |
|---|---|---|
1 |
at least one match found | the pattern matched |
0 |
no match | the input failed the pattern |
false |
the match failed to execute | malformed pattern, backtrack/recursion limit, invalid UTF-8 |
1 and 0 are results; false is an error, and silently treating it as "no match" hides real failures. Production code compares with !== and separates the error case:
$result = preg_match('/^[a-z][a-z0-9_]{2,15}$/', $username);
if ($result === 1) {
// accepted
} elseif ($result === 0) {
// rejected by the rule
} else {
error_log('regex failed: ' . preg_last_error_msg());
throw new RuntimeException('Validation unavailable');
}
preg_last_error_msg() (PHP 8.0+) returns a readable reason such as Malformed UTF-8 characters, possibly incorrectly encoded. On PHP 7 you compare preg_last_error() against constants like PREG_BAD_UTF8_ERROR or PREG_BACKTRACK_LIMIT_ERROR. The backtrack limit (default 1,000,000, controlled by pcre.backtrack_limit) exists so a pathological pattern cannot pin a CPU core for minutes; when honest input exceeds it you can raise the limit with ini_set(), but usually the pattern itself is the bug.
Two details separate working validation from broken validation:
Anchors. preg_match('/[a-z]+/', $input) matches any string containing at least one lowercase letter, so "123abc!!!" passes a username check. The pattern must be anchored — '/^[a-z]+$/' — so the entire input is subject to the rule. In multi-line input, ^ and $ still bind to the start and end of the whole string unless the m modifier is enabled.
Quoting. If any part of the pattern comes from a variable or user input, pass it through preg_quote($value, '/') first. It escapes regex metacharacters (. \ + * ? [ ^ ] $ ( ) { } = ! < > | : - #) and the delimiter you name, so searching for the literal 1+1 does not become a broken quantifier expression.
Which tool? PHP ships filter_var($email, FILTER_VALIDATE_EMAIL), and for standard formats you should usually use it — nobody implements RFC 5322 in a regex and wins. Reach for preg_match when the rule is yours: product SKUs, referral slugs, YYYY-MM-DD dates, query-string prefixes. An interviewer who probes will accept "regex for our own formats, filter_var for the standard ones" far better than a regex that pretends to validate email.
When you need the position of the hit, pass PREG_OFFSET_CAPTURE: preg_match('/@/', $s, $m, PREG_OFFSET_CAPTURE) fills $m[0] with ['@', 3] pairs. The offsets are byte positions, so slicing a UTF-8 subject at them needs the same care as the rest of your multi-byte handling.
When you need more than yes/no, preg_match fills a capture array: positional groups land in matches[1], matches[2], and named groups — (?<year>\d{4}) — land as $matches['year']. Named groups survive reordering the pattern; positional indexes do not.
This return-value discipline is the most common regex interview question in PHP screens: "what does preg_match return?" Half of candidates answer "true or false," and that answer tells you they have never debugged a failing pattern in production.
Delimiters, Modifiers, and the Anchors That Decide What Matches
Every PHP regex is wrapped in a delimiter, and the delimiter is not part of the pattern. /^[a-z]+$/i uses /; #^\d{3}-\d{4}$# uses #. Any non-alphanumeric, non-backslash, non-whitespace ASCII character works, and paired characters — ( ) [ ] { } < > — wrap the pattern instead of flanking it: {^\d+$}m. You pick whichever delimiter does not appear in the pattern itself, which is why Windows-path patterns are written with # so the backslashes stay literal.
The pattern ends at the first unescaped occurrence of the delimiter, and everything after it is a modifier:
| Modifier | Effect | Example |
|---|---|---|
i |
case-insensitive | /^admin$/i matches Admin, ADMIN |
m |
multiline: ^/$ match at line breaks |
/^Subject:/mi scans email headers |
s |
dot matches newline | /first.*last/s spans lines |
x |
ignore whitespace; # starts a comment |
long patterns broken into readable lines |
u |
subject (and pattern) are UTF-8 | /^\p{L}+$/u for Unicode letters |
D |
$ does not also match a trailing \n |
/^[a-z]+$/D rejects "abc\n" |
The UTF-8 flag deserves its own warning. Without /u, \w, \d, \s, and character classes operate on bytes. preg_match('/^\w+$/', 'José') fails: é is two bytes in UTF-8 and neither byte is a \w byte. With /u the same pattern passes, and \p{L}-style Unicode property classes become available. Pair /u with mb_* string functions everywhere user text is involved — String Manipulation Functions covers why strlen alone is not enough.
The x modifier turns a maintenance liability into readable code:
$pattern = '/^
(?<user>[a-z0-9._-]+) # local part
@
(?<host>[a-z0-9.-]+) # domain
\.
(?<tld>[a-z]{2,}) # top-level
$/ix';
In the real file that is a multi-line single-quoted string; whitespace inside it is ignored under x, comments run to end of line, and literal spaces must be escaped or placed inside a character class.
Delimiter mistakes surface as warnings that look nothing like regex. A missing closing delimiter produces preg_match(): Unknown modifier '$' — the parser consumed the rest of the pattern as modifiers and choked on one it did not recognize. The classic beginner break is an unescaped slash in a slash-delimited pattern: /C:\Users/ parses as pattern C:\Users followed by the garbage modifier Users, which is exactly the Unknown modifier warning. The fixes are #C:\\Users# or preg_quote().
Failure shapes differ by PHP version. A delimiter that is alphanumeric, a backslash, or NUL throws a ValueError on PHP 8 with the message Delimiter must not be alphanumeric, backslash or NUL; PHP 7 emitted a warning and returned false. Pattern-level compile errors — unbalanced parentheses, an impossible range like [a-Z] — still warn and return false even on PHP 8, which is exactly why the error handling from the previous chapter keys off the return value instead of trying to intercept warnings. Paired delimiters add one more trap: nesting must balance, so {a{2}}-shaped patterns get ambiguous. Most teams therefore standardize on /, #, or ~ and never argue about it in review.
Interviewers favor the delimiter question because it exposes whether you have actually read a PHP warning. "Why # in that pattern?" has a one-line answer — the pattern is full of / characters, so choosing / would force escaping every one of them. The deeper answer names the parse order: delimiter first, then modifiers, then the rest is PCRE syntax.
Substituting and Scanning Everywhere: preg_replace, preg_match_all, and Callbacks
preg_replace is the counterpart to preg_match, and its default is the part people forget: it replaces every occurrence, not just the first. preg_replace('/-/', '_', 'a-b-c') returns a_b_c. The optional $limit parameter caps the count — preg_replace('/-/', '_', 'a-b-c', 1) yields a_b-c — and the optional $count argument reports how many replacements happened, which is how you detect "nothing changed" cases.
Backreferences in the replacement string use $1 (or ${1} when followed by other digits):
$iso = '2024-03-09';
$uk = preg_replace('/^(\d{4})-(\d{2})-(\d{2})$/', '$3/$2/$1', $iso);
// '09/03/2024'
When the replacement itself must be computed, preg_replace_callback runs a function per match. This is the sanctioned replacement for the e modifier, which evaluated the replacement string as PHP code; it was deprecated in PHP 5.5 and removed in PHP 7.0. Its history is a standard "how did this get hacked" interview story: preg_replace('/(.+)/e', '$1', $input) executes attacker-controlled strings in your process.
$escaped = preg_replace_callback(
'/[&<>"\']/',
fn (array $m) => htmlspecialchars($m[0], ENT_QUOTES, 'UTF-8'),
$userInput
);
If the callback returns null, preg_replace/preg_replace_callback return null (PHP 8+; older versions returned the subject unchanged) — always check the return before using it.
preg_match_all keeps scanning where preg_match stops. It returns the number of matches and fills $matches column-wise: $matches[0] is every full match, $matches[1] every capture of group 1. Extracting all hrefs from markup:
$html = '<a href="/a">x</a><a href="/b">y</a>';
preg_match_all('/href="([^"]+)"/', $html, $m);
// $m[1] === ['/a', '/b']
Column-wise layout is why nested capture groups are awkward with preg_match_all — group 1 may be unset in some rows and PHP pads with empty strings. Restructure with alternation, or loop over PREG_OFFSET_CAPTURE results instead.
Function choice at a glance:
| Task | Function |
|---|---|
| validate, one hit is enough | preg_match |
| extract every occurrence | preg_match_all |
| substitute everywhere | preg_replace |
| substitute the first hit only | preg_replace with $limit = 1 |
| computed substitution | preg_replace_callback |
| split on a pattern | preg_split |
| keep only matching elements | preg_filter |
preg_split belongs in the same toolbox: preg_split('/\s*,\s*/', 'a, b ,c') splits on commas and eats the surrounding whitespace, which explode cannot do. The PREG_SPLIT_NO_EMPTY flag drops empty pieces, PREG_SPLIT_DELIM_CAPTURE keeps captured delimiters in the result, and PREG_SPLIT_OFFSET_CAPTURE returns byte offsets — same flag names as everywhere else in the preg_* family.
preg_filter is the easy-to-miss sibling of preg_replace: it performs the same substitutions but returns only what was actually replaced. On an array, non-matching elements are dropped instead of returned unchanged; on a string with no match it returns an empty string. That makes it the one-liner for "give me just the rows whose field matched."
Do not reach for regex when a string function will do: explode for a literal separator, str_replace for a literal substitution. They are faster and immune to pattern errors. PHP's preg_* family runs PCRE with JIT compilation (since PHP 7.3), which makes repeated matches much faster — but catastrophic backtracking is still real. A pattern like (a+)+$ with input aaaaaaaaaaaaaaaaaaaaX forces exponential backtracking; keep quantifiers non-nested and validate input length before matching a complex pattern.
Quiz
1. What does `preg_match('/^[a-z]+$/', '123')` return?
2. `preg_match('/^\w+$/', 'José')` fails on a UTF-8 string. What is the correct fix?
3. What does `preg_replace('/-/', '_', 'a-b-c')` return?
4. A legacy pattern uses the /e modifier to evaluate replacement code. What is the correct modern approach?
Flashcards
Question
What are preg_match's three return values and what does each mean?
Click to reveal answer
Answer
1 = at least one match; 0 = the pattern executed but found nothing; false = the match itself failed (malformed pattern, backtrack/recursion limit, bad UTF-8). Because false is falsy, `if (!preg_match(...))` conflates 'invalid input' with 'broken regex' — compare with !== and check preg_last_error_msg() on the false path.
Question
Why does every PHP regex start with a delimiter, and when do you choose # over /?
Click to reveal answer
Answer
The delimiter wraps the pattern (any non-alphanumeric non-backslash character, or a paired bracket); everything after the closing delimiter is a modifier. Choose # (or another character absent from the pattern) when the pattern itself contains many slashes — URLs, paths, dates — so nothing needs escaping inside.
Question
How does the output of preg_match_all differ from preg_match?
Click to reveal answer
Answer
preg_match stops at the first hit and returns 1/0/false; preg_match_all scans the whole subject, returns the match count, and fills $matches column-wise: $matches[0] = every full match, $matches[1] = every capture of group 1, with empty-string padding when a group did not participate.
Question
Why does /^\w+$/ fail on 'José', and what fixes it?
Click to reveal answer
Answer
Without the u modifier PCRE matches bytes; 'é' is two UTF-8 bytes and neither is a \w byte, so the match fails. Adding /u makes the pattern and subject UTF-8-aware so classes and quantifiers operate on characters; \p{L} property classes also become available.
Question
What replaced the /e modifier in preg_replace, and why was it removed?
Click to reveal answer
Answer
preg_replace_callback, which computes each replacement in a real function. The e modifier evaluated the replacement string as PHP code (deprecated in PHP 5.5, removed in PHP 7.0), turning any attacker-influenced match into code execution in your process.
Revision Notes
Key Takeaways
- 1. preg_match returns 1, 0, or false — false means the regex failed to run (bad pattern, limits, bad UTF-8), not that there was no match, so compare with !== and log preg_last_error_msg()
- 2. Every pattern is wrapped in a delimiter; pick one absent from the pattern (# around slashes-heavy patterns), and everything after the closing delimiter is a modifier
- 3. Validation patterns must be anchored with ^ and $, and dynamic fragments must pass through preg_quote() or they change the pattern's meaning
- 4. The u modifier is mandatory for UTF-8 text; without it classes like \w operate on bytes and any non-ASCII input breaks matching
- 5. preg_replace substitutes all occurrences by default, preg_match_all scans everything with column-wise captures, and /e is gone since PHP 7.0 — callbacks are the replacement
Interview Tips
- • "What does preg_match return?" — answer 1/0/false and immediately distinguish 'no match' from 'error'; then mention preg_last_error_msg() and you have separated yourself from most candidates
- • "How do you validate a username with regex?" — anchored pattern in delimiters, character class with length bounds, strict !== checks, and preg_quote for any dynamic part; say why /[a-z]+/ without anchors is wrong
- • "Why does my regex work on ASCII but fail on user data?" — byte-wise matching without /u; show the fix and pair it with mb_* functions; naming PREG_BAD_UTF8_ERROR shows depth
- • "What was wrong with the /e modifier?" — it evaluated replacement strings as PHP, enabling code injection; preg_replace_callback replaced it in the 5.5-to-7.0 timeline
Cheat Sheet
Pattern Matching with preg Functions
Return values of preg_match: 1 match · 0 clean non-match · false execution error (check preg_last_error_msg(), PHP 8.0+).
Function picker:
| Task | Function |
|---|---|
| validate / one hit | preg_match($p, $s) |
| extract everything | preg_match_all($p, $s, $m) |
| replace everywhere | preg_replace($p, $r, $s) |
| replace once | preg_replace($p, $r, $s, 1) |
| computed replace | preg_replace_callback($p, $fn, $s) |
| split by pattern | preg_split($p, $s) |
Modifiers: i case · m multiline ^$ · s dot-matches-newline · x extended/comments · u UTF-8 · D $ ignores trailing \n.
Anchored validation template:
$r = preg_match('/^[a-z][a-z0-9_]{2,15}$/', $input);
if ($r === 1) { /* ok */ }
elseif ($r === 0) { /* rejected */ }
else { throw new RuntimeException(preg_last_error_msg()); }
Remember: user-supplied pattern fragments go through preg_quote($v, '/'); /u with mb_* for any non-ASCII text; /e was removed in PHP 7.0 — use preg_replace_callback; keep quantifiers non-nested to avoid catastrophic backtracking.