preg_replace_callback: Regex Replacements With a Brain
preg_replace with backreferences covers rearranging text. The moment a replacement needs a decision - look something up, format a number, escape conditionally - you need code per match, and that's exactly what the callback variant is:
// Turn [product:42] shortcodes into live links
$html = preg_replace_callback(
'/\[product:(\d+)\]/',
function (array $m) use ($products) {
$product = $products[(int) $m[1]] ?? null;
return $product
? sprintf('<a href="%s">%s</a>', e($product->url), e($product->title))
: ''; // unknown id: remove the shortcode
},
$content
);
$m[0] is the full match, $m[1]+ the capture groups. The callback returns the replacement - which can come from a database, a formatter, anywhere. This is the engine behind every shortcode system, template variable expander and mention-linker (@username) you've used; my own CMS renders its
Named groups keep bigger patterns sane
$pattern = '/(?<currency>USD|EUR)\s*(?<amount>\d+(?:\.\d{2})?)/';
$text = preg_replace_callback($pattern, function ($m) {
return formatMoney((float) $m['amount'], $m['currency']); // by NAME
}, $text);
(?<name>...) groups arrive as string keys alongside the numeric ones - self-documenting inside the callback and immune to renumbering when the pattern grows a group. Anything past two capture groups should be named; future-you agrees.
preg_quote: the companion that prevents injection-adjacent bugs
// Highlighting a user's search term - the term may contain . * ( ) + ...
$safe = preg_quote($query, '/'); // 2nd arg: also escape your delimiter!
$html = preg_replace_callback(
"/{$safe}/i",
fn ($m) => '<mark>' . e($m[0]) . '</mark>',
e($text)
);
Interpolating raw user input into a pattern means a search for "c++" throws a compilation warning, and cleverer inputs can change what your regex matches. preg_quote escapes every regex metacharacter - and the frequently-forgotten second argument escapes the delimiter too. Any variable inside a pattern should pass through it; treat exceptions as code review findings. (Same philosophy as escaping LIKE wildcards - metacharacters in user input are always someone's bug.)
Notes from the trenches
- Multiple patterns with different logic: preg_replace_callback_array(['/pat1/' => $fn1, '/pat2/' => $fn2], $text) - one pass, dispatched per pattern.
- The callback must be fast: it runs per match, and a DB query per match on a long document is an accidental N+1. Prefetch (as the $products closure above implies), then replace.
- Returning null from the callback is a silent ""-replacement; be explicit about the unknown-match branch, as with the empty-string return above.
Between shortcodes, highlighters, linkers and normalizers, this function shows up in most real codebases eventually - usually written badly with preg_match_all + str_replace loops first. Skip to the tool built for it.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.