PHPのpreg_quoteで検索語を正規表現に安全に埋め込む。区切り文字の渡し忘れと置換文字列の落とし穴
preg_quoteは、ユーザー入力や変数の文字列を正規表現の中に「ただの文字列」として埋め込むために、正規表現の特殊文字をエスケープする関数です。第2引数に区切り文字を渡し忘れると、静かにパターンが壊れます。
検索語をそのまま正規表現に連結する書き方、実務ではけっこう見かけます。私もやったことがあります。テストの入力が普通の英単語のうちは動いてしまうので、気づくのがだいぶ後になるんですよね。
検索語をそのまま連結すると何が起きるのか
たとえばハイライト機能で、入力された検索語を '/' . $kw . '/u' のようにパターンへ連結したとします。. や ( や [ が入った瞬間に、意図しないパターンに化けます。
<?php
$text = '(株)山田商事 と arr[0] と 1+1=2';
// 「(株)」はグループ扱いになり、括弧そのものではなく「株」にマッチする
preg_match('/' . '(株)' . '/u', $text, $m);
echo $m[0]; // 株
// 「arr[」は文字クラスが閉じておらずコンパイルエラー(E_WARNING)、戻り値はfalse
$r = preg_match('/' . 'arr[' . '/u', $text);
var_dump($r); // bool(false)
// 「1+1」は「1が1回以上のあとに1」と解釈されるので、元の文字列「1+1」にはマッチしない
var_dump(preg_match('/' . '1+1' . '/u', $text)); // int(0)
怖いのは、エラーにならずに「違うものにマッチする」ケースです。「(株)」の例は警告も出ません。ログにも残らないまま、結果だけが少しずれます。
preg_quoteの基本の使い方はどう書く?
シグネチャは preg_quote(string $str, ?string $delimiter = null): string です。第1引数が埋め込みたい文字列、第2引数が「パターンの区切り文字として使っている1文字」です。
<?php
$text = '(株)山田商事 と arr[0] と 1+1=2';
foreach (['(株)', 'arr[', '1+1'] as $kw) {
$pattern = '/' . preg_quote($kw, '/') . '/u';
preg_match($pattern, $text, $m);
echo $m[0], "\n";
}
// (株)
// arr[
// 1+1
これで、検索語に何が入っていても「その文字列そのもの」にマッチします。パターンを組み立てる側が、入力の中身を気にしなくてよくなるのが一番の利点だと思います。
なぜ第2引数の区切り文字が必要なのか
preg_quoteは、デフォルトでは区切り文字をエスケープしません。/ は正規表現の特殊文字ではないからです。つまり、区切り文字を渡さないと、パターンを閉じる文字が入力に混ざったときに壊れます。
<?php
echo preg_quote('a/b~c', '~'), "\n"; // a/b\~c (~が区切り文字なので~だけ追加で退避される)
echo preg_quote('a/b~c'), "\n"; // a/b~c (どちらも退避されない)
// 区切りを ~ にしているのに第2引数を渡さないと、パターンが途中で閉じてしまう
var_dump(@preg_match('~' . preg_quote('a/b~c') . '~', 'a/b~c')); // bool(false)
区切り文字を / 以外にしたときほど忘れやすいところです。私は、区切り文字を変数や定数にまとめて、パターンの組み立てと preg_quote の両方で同じものを使うようにしています。
なお、# はPHP 7.3以降、デフォルトでエスケープされます(xフラグ付きで使うときのためです)。古いPHPのコードを読むときは、そこだけ挙動が違う可能性があります。スペースはエスケープされないので、xフラグ(コメントと空白を無視するモード)と組み合わせるときは、空白が無視される点に注意してください。
複数のキーワードを OR で連結するときの落とし穴
キーワードの配列を | でつなぐパターンは、ハイライトやNGワード検出でよく書きます。ここでは、それぞれに preg_quote をかけるのと、並び順の2点を押さえておくと安心です。
<?php
$kws = ['C', 'C++', '.NET'];
// 長い語を先に並べる。先頭から順に試されるので、短い 'C' が先だと 'C++' の途中で確定してしまう
usort($kws, fn(string $a, string $b): int => strlen($b) <=> strlen($a));
$pattern = '/(?:' . implode('|', array_map(
fn(string $k): string => preg_quote($k, '/'),
$kws
)) . ')/u';
echo $pattern, "\n"; // /(?:\.NET|C\+\+|C)/u
preg_match($pattern, 'C++', $m);
echo $m[0]; // C++
パターンは先頭の候補から順に試され、最初に成功した候補で確定します。['C', 'C++'] の順だと C だけで確定するので、長い語を先に並べ替えてから連結しています。strlen はバイト数ですが、並び順を決めるだけなので日本語が混ざっていても、ここでは実害はありません。
置換文字列には preg_quote が効かない
もうひとつの落とし穴が、preg_replace の第2引数(置換文字列)です。ここでは $1 や \1 が後方参照として解釈されます。preg_quoteは「パターン側」用の関数なので、置換文字列には使えません。
<?php
// 置換文字列の「$5」が5番目のグループへの参照と解釈され、存在しないので空になる
echo preg_replace('/foo/', 'cost $5', 'foo'), "|\n"; // cost |
// コールバックなら戻り値がそのまま使われる
echo preg_replace_callback('/foo/', fn(array $m): string => 'cost $5', 'foo'), "|\n"; // cost $5|
金額の「$100」のような文字列が置換側に入りうるなら、preg_replace_callback でコールバックの戻り値として返すのが確実です。ハイライト用の関数はこうなります。
<?php
function highlight(string $text, string $kw): string
{
$pattern = '/' . preg_quote($kw, '/') . '/u';
return preg_replace_callback(
$pattern,
fn(array $m): string => '【' . $m[0] . '】',
$text
);
}
echo highlight('価格は$100です。$100は税込。', '$100');
// 価格は【$100】です。【$100】は税込。
パターン側は preg_quote、置換側はコールバック、と役割を分けて覚えておくと、あとで迷いません。
そもそも正規表現が要らない場面もある
単に「含まれているか」を調べたいだけなら、str_contains や mb_strpos のほうが速くて読みやすいです。preg_quoteが活きるのは、大文字小文字の無視(iフラグ)や単語境界、他のパターンとの組み合わせなど、正規表現の機能を使いたいときだと思います。
まとめ
変数を正規表現に埋め込むときは、preg_quote を通して、第2引数にパターンの区切り文字を渡す。この2点だけで、特殊文字まわりの事故はほぼ防げます。置換文字列には効かないので、そちらは preg_replace_callback に逃がす。個人的には、このあたりを癖にしておくと、あとからの見直しがだいぶ楽になる気がしています。
よくある質問
Q. preg_quoteの第2引数は省略してもいいですか?
A. 省略しても動きますが、区切り文字はエスケープされません。パターンの区切りに使っている文字が入力に含まれると壊れるので、区切り文字を必ず渡すのが安全です。
Q. preg_quoteはSQLインジェクションの対策になりますか?
A. なりません。正規表現の特殊文字をエスケープするだけの関数です。SQLにはプリペアドステートメントを使ってください。
Q. 日本語(マルチバイト)の文字列をpreg_quoteに渡しても大丈夫ですか?
A. 特殊文字はASCIIなので、日本語はそのまま残ります。パターン側にuフラグを付けておけば、UTF-8の文字列として扱われます。