正規表現で文字列を置換するときによく使うのが preg_replace() です。ただしこの関数は、置換後の文字列をあらかじめ決めておく必要があります。「マッチした数字を1.1倍した値に差し替えたい」「見つけた URL をリンクタグで囲みたい」のように、マッチした内容を見てから置換後の文字列を組み立てたい場面では手が届きません。
そこで使うのが preg_replace_callback() です。マッチするたびに自分で書いた関数が呼ばれるので、その中で計算や加工を行い、結果を戻り値として返せば置換できます。この記事では基本構文と引数の意味、コールバックが受け取る $matches 配列の中身、数値のフォーマットや Markdown 風記法の変換といった実践例、そして思ったように置換されないときの原因までまとめて解説します。
目次
preg_replace() では書けないこと
まず preg_replace() の限界を確認しておきます。第2引数には置換後の文字列を渡しますが、ここに書けるのは固定の文字列と、$1 や \1 のような後方参照だけです。マッチした部分をそのまま別の位置に差し込むことはできても、値そのものを加工することはできません。
<?php
$text = '通常価格は1000円、セール価格は800円です。';
// 数字を「***」のような固定の文字列に置き換えるのは得意
echo preg_replace( '/\d+/', '***', $text );
// 通常価格は***円、セール価格は***円です。
// 後方参照で「見つけた数字」を使い回すこともできる
echo preg_replace( '/(\d+)円/', '$1 yen', $text );
// 通常価格は1000 yen、セール価格は800 yenです。
// しかし「見つけた数字を1.1倍する」のような計算は書けない
置換後の文字列は PHP のコードではなく、ただの文字列として扱われます。'$1 * 1.1' と書いても計算されず、そのまま出力されるだけです。この「マッチした値を使って処理をしたい」という要求に応えるのが preg_replace_callback() です。
preg_replace_callback() の基本構文
シグネチャは次のとおりです。preg_replace() の第2引数が、文字列からコールバック(呼び出される関数)に置き換わった形になっています。
preg_replace_callback(
string|array $pattern,
callable $callback,
string|array $subject,
int $limit = -1,
int &$count = null,
int $flags = 0
): string|array|null
| 引数 | 説明 |
|---|---|
$pattern | 検索する正規表現パターン。デリミタで囲んだ文字列を渡す。配列を渡すと、複数のパターンを順番に適用する |
$callback | マッチするたびに呼ばれる関数。マッチ結果の配列を1つだけ受け取り、置換後の文字列を返す |
$subject | 置換の対象となる文字列。配列を渡すと各要素に対して置換を行い、結果も配列で返る |
$limit | 各パターンごとの置換回数の上限。既定値の -1 は無制限を意味する |
$count | 参照渡しの変数。実際に置換が行われた回数が代入される |
$flags | PREG_OFFSET_CAPTURE と PREG_UNMATCHED_AS_NULL を指定できる(PHP 7.4 以降) |
戻り値は置換後の文字列です。$subject に配列を渡した場合は配列が返り、エラーが起きた場合は null が返ります。
最小の例を見てみましょう。先ほど preg_replace() では書けなかった「数字を1.1倍する」処理です。
<?php
$text = '通常価格は1000円、セール価格は800円です。';
$result = preg_replace_callback(
'/\d+/', // 第1引数: パターン
function ( array $matches ): string { // 第2引数: コールバック
// $matches[0] にマッチした文字列が入っている
$price = (int) $matches[0];
return (string) (int) round( $price * 1.1 );
},
$text // 第3引数: 対象の文字列
);
echo $result;
// 通常価格は1100円、セール価格は880円です。
コールバックは、マッチした箇所の数だけ呼び出されます。この例では 1000 と 800 の2回です。そしてコールバックが return した文字列が、そのマッチ箇所と入れ替わります。ここが preg_replace() との最大の違いです。
なお $callback の型は callable なので、無名関数以外に、名前付き関数の名前を書いた文字列や [ $obj, 'method' ] 形式の配列も渡せます。ただし渡される引数は常に「マッチ結果の配列1つ」なので、それを受け取れる関数である必要があります。'strtoupper' のように文字列を受け取る組み込み関数をそのまま渡すことはできません。
コールバックが受け取る $matches 配列
コールバックの引数には、preg_match() の第3引数と同じ形式のマッチ結果が配列で渡されます。中身を var_dump() で覗いてみます。
<?php
$text = '公開日: 2026-09-21';
preg_replace_callback(
'/(\d{4})-(\d{2})-(\d{2})/',
function ( array $matches ): string {
var_dump( $matches );
return $matches[0];
},
$text
);
// array(4) {
// [0] => string(10) "2026-09-21" マッチした部分全体
// [1] => string(4) "2026" 1つめの ( ) の中身
// [2] => string(2) "09" 2つめの ( ) の中身
// [3] => string(2) "21" 3つめの ( ) の中身
// }
$matches[0] はマッチした部分全体で、$matches[1] 以降が丸括弧で囲んだキャプチャグループです。番号は、パターンの中で開き括弧が現れた順に1から振られます。キャプチャグループを使っていないパターンなら、配列には $matches[0] しか入りません。
この仕組みが分かれば、日付の表記を組み替えるような処理もすぐ書けます。
<?php
$text = '公開日: 2026-09-21 / 更新日: 2026-09-30';
echo preg_replace_callback(
'/(\d{4})-(\d{2})-(\d{2})/',
function ( array $m ): string {
// %d に渡すと "09" のようなゼロ埋めも 9 として扱われる
return sprintf( '%d年%d月%d日', $m[1], $m[2], $m[3] );
},
$text
);
// 公開日: 2026年9月21日 / 更新日: 2026年9月30日
名前付きキャプチャ (?<year>\d{4}) を使った場合は、$matches['year'] のように名前でも取り出せます。番号付きの要素も同時に入っているので、どちらで参照しても構いません。パターンが長くなるときは名前を付けたほうが読みやすくなります。
実践的な使い方
ここからは、実際の開発で書く機会が多いパターンを4つ紹介します。
文中の数字を3桁区切りにする
文章の中に混ざっている数値だけを number_format() に通して、カンマ区切りに整える例です。数値が何個あるか、どこにあるかを気にせず書けるのがコールバックの利点です。
<?php
$text = '今月の売上は1234567円、前年同月は9876543円でした。';
echo preg_replace_callback(
'/\d+/',
function ( array $m ): string {
return number_format( (int) $m[0] );
},
$text
);
// 今月の売上は1,234,567円、前年同月は9,876,543円でした。
ただしこのパターンは、日付やバージョン番号に含まれる数字にも反応してしまいます。区切る対象を絞りたいときは '/(\d+)円/' のように前後の文字まで含めてマッチさせ、コールバックの中で $m[1] を整形して「円」を付け直す形にすると安全です。
Markdown 風の記法を HTML タグに変換する
**太字** のような簡易記法を <strong> タグに変換する処理です。ユーザーが入力した文字列を HTML として出力するので、先に htmlspecialchars() で全体をエスケープしてから、許可した記法だけをタグに戻すという順序が大切です。逆にすると、自分で生成した <strong> までエスケープされてしまいます。
<?php
$text = 'これは **重要なお知らせ** です。<script>alert(1)</script>';
// 1. まず全体をエスケープする
$escaped = htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
// 2. 許可した記法だけをタグに変換する
$html = preg_replace_callback(
'/\*\*(.+?)\*\*/u',
function ( array $m ): string {
return '<strong>' . $m[1] . '</strong>';
},
$escaped
);
echo $html;
// これは <strong>重要なお知らせ</strong> です。<script>alert(1)</script>
パターンの (.+?) は最短マッチです。? を付けずに (.+) と書くと、1行に ** が4つあるときに最初と最後を1組と見なしてしまい、途中の文字まで丸ごと太字になります。末尾の u は UTF-8 として扱うための修飾子で、日本語を含む文字列を扱うときは付けておきます。
文中の URL を自動でリンクにする
プレーンテキストの中にある URL を <a> タグで囲む処理です。ここでも、リンクテキストと href 属性に入れる値の両方をエスケープしておきます。
<?php
function auto_link( string $text ): string {
// 出力先が HTML なので、まず全体をエスケープする
$escaped = htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
return preg_replace_callback(
'#https?://[^\s<>"]+#u',
function ( array $m ): string {
$url = $m[0];
return '<a href="' . $url . '" target="_blank" rel="noopener">'
. $url
. '</a>';
},
$escaped
);
}
echo auto_link( '詳しくは https://www.php.net/manual/ja/ を見てください。' );
// 詳しくは <a href="https://www.php.net/manual/ja/" target="_blank" rel="noopener">https://www.php.net/manual/ja/</a> を見てください。
パターンのデリミタに / ではなく # を使っているのは、URL に含まれるスラッシュをいちいちエスケープしなくて済むようにするためです。デリミタは # や ~ など、英数字とバックスラッシュ以外の記号なら自由に選べます。
なお [^\s<>"]+ は「空白と記号以外を貪欲に拾う」という簡易的な書き方なので、日本語の句点は空白ではない都合上、https://example.com/。 のように直後に句読点が続くと一緒に飲み込んでしまいます。厳密に扱いたい場合は、末尾の記号を除外する調整が必要です。
スネークケースをキャメルケースに変換する
データベースのカラム名をプロパティ名に変換するときなど、命名規則を変換したい場面はよくあります。「アンダースコアと、その次の1文字」をマッチさせて、ucfirst() で大文字にしたものに置き換えます。アンダースコア自体は置換後の文字列に含めないので、自然に消えます。
<?php
function to_camel_case( string $snake ): string {
return preg_replace_callback(
'/_([a-z0-9])/',
function ( array $m ): string {
// $m[1] は _ の直後の1文字
return ucfirst( $m[1] );
},
$snake
);
}
echo to_camel_case( 'user_first_name' ); // userFirstName
echo to_camel_case( 'post_id' ); // postId
// 先頭も大文字にすればパスカルケースになる
echo ucfirst( to_camel_case( 'blog_post' ) ); // BlogPost
逆にキャメルケースをスネークケースへ戻したいときは、大文字をマッチさせて '_' . strtolower( $m[0] ) を返します。こちらは先頭が大文字だと先頭にもアンダースコアが付くので、最後に ltrim( $result, '_' ) を掛けておくと安全です。
無名関数・use・アロー関数
コールバックは、ここまでの例のように無名関数(クロージャ)でその場に書くのが一般的です。1か所でしか使わない短い処理をわざわざ名前付き関数として切り出す必要はありません。
無名関数の中からは、外側のスコープの変数がそのままでは見えません。税率や設定値など外の値を使いたい場合は、use で取り込みます。
<?php
$taxRate = 1.1;
$text = '本体価格は1000円、送料は500円です。';
echo preg_replace_callback(
'/(\d+)円/',
function ( array $m ) use ( $taxRate ): string { // 外の変数を取り込む
$price = (int) round( (int) $m[1] * $taxRate );
return number_format( $price ) . '円(税込)';
},
$text
);
// 本体価格は1,100円(税込)、送料は550円(税込)です。
処理が1行で収まるなら、PHP 7.4 以降のアロー関数 fn を使うともっと短く書けます。アロー関数は外側の変数を自動で取り込むので use が不要です。
<?php
$text = '在庫は1234個あります。';
// アロー関数は式の値がそのまま戻り値になる(return を書かない)
echo preg_replace_callback(
'/\d+/',
fn ( array $m ): string => number_format( (int) $m[0] ),
$text
);
// 在庫は1,234個あります。
アロー関数は本体に式を1つしか書けません。条件分岐や複数行の処理が必要になったら、通常の無名関数に戻してください。クラスのメソッドの中で書く場合、$this を使わないなら static function / static fn にしておくと、余計な束縛が起きず意図が明確になります。
置換回数とフラグを活用する
第4引数以降は省略されがちですが、知っておくと便利な場面があります。
$limit と $count
第4引数の $limit は置換回数の上限で、既定値は無制限を表す -1 です。「最初の1件だけ置換する」といった用途で指定します。第5引数の $count は参照渡しで、実際に置換が行われた回数が代入されます。1件も置換されなかったかどうかを判定したいときに使えます。
<?php
$text = 'A社が100、B社が200、C社が300です。';
$result = preg_replace_callback(
'/\d+/',
fn ( array $m ): string => '[' . $m[0] . ']',
$text,
2, // $limit: 先頭から2回だけ置換する
$count // $count: 実際の置換回数が入る
);
echo $result; // A社が[100]、B社が[200]、C社が300です。
echo $count; // 2
PREG_OFFSET_CAPTURE でマッチ位置を知る
PHP 7.4 で追加された第6引数 $flags に PREG_OFFSET_CAPTURE を指定すると、$matches の各要素が「マッチした文字列」と「そのバイト位置」の2要素の配列になります。何文字目にあるマッチなのかによって処理を変えたいときに使います。
<?php
$text = 'error: 1件目 / error: 2件目';
echo preg_replace_callback(
'/error/',
function ( array $m ): string {
// $m[0] は [ マッチした文字列, オフセット ] になる
[ $value, $offset ] = $m[0];
// 先頭にあるものだけ強調する
return $offset === 0 ? strtoupper( $value ) : $value;
},
$text,
-1,
$count,
PREG_OFFSET_CAPTURE
);
// ERROR: 1件目 / error: 2件目
オフセットは文字数ではなくバイト単位で数えられる点に注意してください。日本語を含む文字列では、見た目の文字数とずれます。
PREG_UNMATCHED_AS_NULL で未マッチを揃える
同じく PHP 7.4 で追加された PREG_UNMATCHED_AS_NULL を指定すると、マッチしなかったキャプチャグループが null として配列に入ります。既定の動作では、マッチしなかったグループは空文字になったり、末尾の場合はそもそも配列に含まれなかったりするため、「値が空だったのか、そもそもマッチしなかったのか」を区別できません。このフラグを付けておくと、null かどうかで判定できるようになります。
<?php
$text = '開始 09:30 / 終了 18:00:45';
echo preg_replace_callback(
'/(\d{2}):(\d{2})(?::(\d{2}))?/',
function ( array $m ): string {
// 秒のグループは、無いときに必ず null になる
if ( $m[3] === null ) {
return "{$m[1]}時{$m[2]}分";
}
return "{$m[1]}時{$m[2]}分{$m[3]}秒";
},
$text,
-1,
$count,
PREG_UNMATCHED_AS_NULL
);
// 開始 09時30分 / 終了 18時00分45秒
複数のパターンをまとめる preg_replace_callback_array()
パターンごとに違う処理をしたい場合、preg_replace_callback() を何度も呼ぶ代わりに preg_replace_callback_array() が使えます。キーがパターン、値がコールバックの連想配列を第1引数に渡す形です。
<?php
$text = '**セール中** 全品1980円から!';
echo preg_replace_callback_array(
[
'/\*\*(.+?)\*\*/u' => fn ( array $m ): string => '<strong>' . $m[1] . '</strong>',
'/\d+/' => fn ( array $m ): string => number_format( (int) $m[0] ),
],
$text
);
// <strong>セール中</strong> 全品1,980円から!
パターンは配列に書いた順に適用され、前のパターンの置換結果に対して次のパターンが適用されます。順序によって結果が変わることがあるので、並び順は意識して決めてください。$limit・$count・$flags は preg_replace_callback() と同じように第2引数以降で指定できます。
置換されない・警告が出るときの原因
動かないときは、パターンの書き方よりもコールバック側の書き方に原因があることが多いです。よくあるものを順に見ていきます。
マッチした部分が消えてしまう
「置換したはずの箇所が空になる」という症状のほとんどは、コールバックで return を書き忘れていることが原因です。戻り値のない関数は null を返し、それが文字列に変換されて空文字になるため、マッチした部分がそっくり消えます。
<?php
// 悪い例: echo しているだけで return していない
echo preg_replace_callback( '/\d+/', function ( array $m ) {
echo $m[0]; // 画面には出るが、置換結果は空文字になる
}, '在庫は100個' );
// 100在庫は個
// 良い例
echo preg_replace_callback( '/\d+/', function ( array $m ): string {
return '(' . $m[0] . ')';
}, '在庫は100個' );
// 在庫は(100)個
コールバックの戻り値に : string と型を書いておけば、return を忘れたときや文字列以外を返したときに TypeError が出て、すぐ気づけます。書き忘れが不安な場合は付けておくとよいでしょう。
Undefined array key の警告が出る
? や | を使って「あってもなくてもよいグループ」を書いた場合、そのグループがマッチしなかったときに $matches の要素が存在しないことがあります。特にマッチしなかったグループがパターンの末尾にある場合、その要素は配列に含まれません。存在しないキーを参照すると Warning: Undefined array key 2 のような警告が出ます。
<?php
$text = 'v1.2.3 と v2.0 があります';
echo preg_replace_callback(
'/v(\d+)\.(\d+)(?:\.(\d+))?/',
function ( array $m ): string {
// 3つめのグループは "v2.0" のときに存在しない
$patch = $m[3] ?? '0';
return "{$m[1]}.{$m[2]}.{$patch}";
},
$text
);
// 1.2.3 と 2.0.0 があります
このように ?? で既定値を用意しておくのが手軽な対処です。前述の PREG_UNMATCHED_AS_NULL を指定して、必ず null が入るようにする方法もあります。
日本語が文字化けする・意図しない位置で切れる
正規表現は既定でバイト単位で処理されるため、. や {2} といった数え方が UTF-8 の日本語では1文字と一致しません。たとえば '/./' は日本語1文字(UTF-8 で3バイト)のうち1バイトだけにマッチし、そこで置換すると文字が壊れます。
日本語を含む文字列を扱うときは、パターンの末尾に u 修飾子を付けてください。これで PCRE がパターンと対象文字列を UTF-8 として解釈し、1文字を正しく1単位として扱うようになります。
<?php
$text = 'こんにちは';
// u 修飾子なし: バイト単位で切れて文字化けする
echo preg_replace_callback( '/./', fn ( $m ) => '[' . $m[0] . ']', $text );
// u 修飾子あり: 1文字ずつ正しく処理される
echo preg_replace_callback( '/./u', fn ( $m ) => '[' . $m[0] . ']', $text );
// [こ][ん][に][ち][は]
戻り値が null になる
preg_replace_callback() は、エラーが起きると null を返します。原因は preg_last_error() と、PHP 8.0 以降なら preg_last_error_msg() で確認できます。
<?php
// 例: Shift_JIS のまま読み込んでしまった文字列
$subject = mb_convert_encoding( 'こんにちは', 'SJIS-win', 'UTF-8' );
$result = preg_replace_callback( '/./u', fn ( $m ) => $m[0], $subject );
if ( $result === null ) {
// PHP 8.0 以降はメッセージを直接取得できる
echo preg_last_error_msg(); // 例: Malformed UTF-8 characters, possibly incorrectly encoded
echo preg_last_error(); // 定数と比較できる数値
}
よくあるのが、u 修飾子を付けているのに対象文字列が UTF-8 として不正な場合に返る PREG_BAD_UTF8_ERROR です。Shift_JIS のファイルを読み込んだときなどに起きるので、mb_convert_encoding() で変換してから処理します。
もうひとつ多いのが PREG_BACKTRACK_LIMIT_ERROR です。(.+)+ のようにネストした量指定子を含むパターンを長い文字列に適用すると、バックトラックの回数が pcre.backtrack_limit(既定は100万回)を超えて処理が打ち切られます。上限を上げるより、最短マッチ(.+?)を使う、文字クラスで対象を絞るなど、パターン側を見直すほうが確実です。
なお、パターンの書式そのものが間違っている場合(デリミタの閉じ忘れなど)は null ではなく Warning: preg_replace_callback(): No ending delimiter '/' found のような警告が出ます。警告メッセージにパターンの問題点が書かれているので、まずはそこを読んでください。
まとめ
preg_replace_callback() は、マッチした内容を見てから置換後の文字列を組み立てられる関数です。preg_replace() で固定の文字列や後方参照しか書けなくて詰まったら、この関数に切り替えるのが定石になります。コールバックは $matches 配列を1つ受け取り、$matches[0] にマッチした全体、$matches[1] 以降にキャプチャグループの中身が入っています。返した文字列がそのまま置換結果になります。
書き方は無名関数が基本で、外の変数を使いたいときは use、1行で済むときはアロー関数 fn が便利です。置換回数を知りたければ第5引数の $count、未マッチのグループを null で揃えたければ PREG_UNMATCHED_AS_NULL を使います。複数のパターンを一度に処理したい場合は preg_replace_callback_array() も検討してください。
つまずいたときは、コールバックに return があるか、日本語を扱うパターンに u 修飾子が付いているか、この2点をまず確認すると解決が早いです。