01 · THE IDEAREAD → TRY → LEARN
Why captures?
A plain yes/no regex test tells you that text has a certain shape. Most real tasks need more: you want the year from a sentence, the host from a URL, or the numbers from a version string. Parentheses in a pattern create capture groups, and preg_match() copies whatever each group matched into an array you supply as the third argument.
The signature is preg_match(string $pattern, string $subject, array &$matches = null, int $flags = 0, int $offset = 0): int|false. It returns 1 for a match, 0 for no match, and false if matching failed, for example when the pattern is invalid or a backtracking limit is hit.
Numbered and named groups
Without names, $matches[0] holds the whole match, $matches[1] the first group, $matches[2] the second, and so on. Numbers are fragile: add a group at the start of the pattern and every index shifts. A named group such as (?<year>\d{4}) is written once and read as $matches['year']. PHP still fills in the numeric index too, so a named group appears under both keys.
$text = 'order: 2008';
if (preg_match('/(?<label>[a-z]+): (?<number>\d+)/', $text, $m) === 1) {
echo json_encode($m, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
}{"0":"order: 2008","label":"order","1":"order","number":"2008","2":"2008"}
Notice the order: the full match comes first, then each named group directly followed by its numeric twin.
Handle the non-match explicitly
When the pattern does not match, $matches is an empty array. Reading $m['year'] from it raises a warning about an undefined key and gives you null by accident. Check the return value first, and use the strict comparison === 1, because false and 0 are both falsy but mean different things. Then decide what your function reports for the failure case. A nullable return type such as ?int is a clear, honest choice.
function read_year(string $text): ?int {
$result = preg_match('/\b(?<year>\d{4})\b/', $text, $m);
if ($result !== 1) {
return null;
}
return (int) $m['year'];
}
echo json_encode([read_year('Released in 2024'), read_year('No year here')]), "\n";[2024,null]
Captured values are always strings, so cast them with (int) when you need numbers.
Optional groups and PREG_UNMATCHED_AS_NULL
A group followed by ? may take no part in a match. By default PHP handles that inconsistently: an unmatched group in the middle becomes an empty string, while unmatched trailing groups are left out of the array entirely. That means $m['patch'] might not exist at all. The PREG_UNMATCHED_AS_NULL flag makes the result uniform: every group is present, and unmatched ones are null. This also lets you tell an unmatched group apart from one that matched an empty string.
preg_match('/(?<a>a)(?<b>b)?(?<c>c)?/', 'a', $plain);
preg_match('/(?<a>a)(?<b>b)?(?<c>c)?/', 'a', $nullable, PREG_UNMATCHED_AS_NULL);
echo json_encode([count($plain), array_key_exists('b', $plain), count($nullable), array_key_exists('b', $nullable), $nullable['b']]), "\n";[3,false,7,true,null]
The plain result has three entries and no b key, while the flagged result has seven entries and b is null.
Anchors matter
A pattern without ^ and $ can match anywhere inside the subject. If you want to validate a whole string, anchor both ends. Remember that $ also matches just before a final newline; the D modifier makes it match only at the very end.
Exercise contract
Write parse_version(string $text): ?array. It accepts a version string with an optional leading v, a major and minor number, and an optional patch number: v8.4.25, 8.4, 0.0.0.
- On success, return an array with the integer keys
majorandminor, andpatchas an integer, ornullwhen the patch part is absent. - If the whole string does not have that shape, return
null. Extra text before or after the version means no match, soversion 8.4.25returnsnull. - Use named groups, check
preg_match()strictly against1, and cast the captured strings to integers.
The tests compare the returned values, not printed output, and they include a missing patch, all zeros, surrounding text and an empty string.
Recap
- Groups in parentheses are captured into
$matches; name them with(?<name>...). - Check
preg_match(...) === 1before touching$matches. - Captures are strings; cast them when you need other types.
- Use
PREG_UNMATCHED_AS_NULLso optional groups always exist. - Anchor patterns when validating a whole string.
Functions · Arrays · Types
- Use named captures and handle non-matches explicitly
- Implement and test a deterministic PHP 8.4 function