Skip to content
Carlo Fontanos
Carlo Fontanos

Carlo Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

preg_replace_callback: Regex Replacements With a Brain

C
Carlo Fontanos
· 3 min read

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

Your details are only used to reply to you.

blocks with precisely this function.

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.

C
Written by Carlo Fontanos

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.

Your details are only used to reply to you.

Keep reading