PHPで検索機能を作るとは、検索フォームから受け取ったキーワードをもとに、MySQLのLIKEを使ってデータを部分一致で絞り込んで表示する仕組みのことです。安全に実装する鍵は、検索値をprepared statementで渡してSQLインジェクションを防ぎ、さらにLIKEのワイルドカード(%と_)をエスケープすることです。
商品一覧、記事一覧、会員リスト——一覧があるところには、ほぼ必ず「検索」が求められます。検索機能は一見シンプルですが、サンプルコードをそのまま使うと、SQLインジェクションの穴が空いていたり、ユーザーが「%」を入力すると全件ヒットしてしまったりと、見落としがちな落とし穴が潜んでいます。
この記事では、PHP+MySQLで検索機能を一から実装する手順を、動くコードと「なぜそう書くか」をセットで解説します。LIKEによる部分一致、ワイルドカードのエスケープ、複数キーワードでの絞り込み、0件時の処理まで、実務でそのまま使える形でまとめます。データベース操作の基礎はPHPでMySQLを操作する方法(CRUD)で扱っているので、本記事はその検索編という位置づけです。
PHPで検索機能を作るとは(全体像)
検索機能の処理は、突き詰めると3ステップです。①検索フォームでキーワードを受け取る、②そのキーワードでSELECT ... WHERE name LIKE ...を実行する、③ヒットした結果を一覧表示する。中心になるのが、部分一致を実現するMySQLのLIKE句です。
LIKEでは、ワイルドカード%(任意の0文字以上)と_(任意の1文字)を使ってパターンを表現します。検索でよく使うのは、キーワードを%で挟む「部分一致」です。
| パターン | 意味 | 例:「コーヒー」で |
|---|---|---|
| ‘コーヒー%’ | 前方一致(〜で始まる) | コーヒー豆 ◎ / 深煎りコーヒー ✕ |
| ‘%コーヒー’ | 後方一致(〜で終わる) | 深煎りコーヒー ◎ / コーヒー豆 ✕ |
| ‘%コーヒー%’ | 部分一致(〜を含む) | どちらも ◎(検索の基本) |
本記事では、もっとも使う「部分一致(%キーワード%)」を軸に実装していきます。検索はデータの取得(Read)の応用なので、SELECTやprepared statementの基本はCRUDの実装記事を前提にします。
事前準備:テーブルと検索フォーム
CRUD記事と同じitemsテーブル(商品)を使います。name(商品名)で検索する想定です。まずは検索フォームを用意します。検索は通常、結果をURLで共有・ブックマークできるようmethod="get"で送ります。
<form action="search.php" method="get">
<input type="text" name="keyword"
placeholder="商品名で検索"
value="<?php echo htmlspecialchars($_GET['keyword'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
<button type="submit">検索</button>
</form> 入力欄のvalueに検索済みのキーワードをhtmlspecialchars()でエスケープして表示しておくと、検索後も入力が残って使い勝手が良くなります。エスケープを忘れるとXSSの原因になるため、ユーザー入力を画面に出すときは必ずエスケープします。
LIKEで部分一致検索を実装する(基本形)
検索本体(search.php)です。フォームから受け取ったキーワードを%で挟み、LIKEで部分一致検索します。ここで最重要なのは、%を付けるのはSQL文の中ではなく、バインドする「値」の側だという点です。
<?php
require 'db.php';
$keyword = trim($_GET['keyword'] ?? '');
$sql = 'SELECT * FROM items WHERE name LIKE :keyword ORDER BY id DESC';
$stmt = $pdo->prepare($sql);
// %を付けるのは「値」の側。SQL内に '%:keyword%' と書いてはいけない
$stmt->execute([':keyword' => '%' . $keyword . '%']);
$items = $stmt->fetchAll(); よくある間違いが、SQLにLIKE '%:keyword%'と書いてしまうこと。これだとプレースホルダが文字列の一部とみなされ、置換されません。正しくは、SQLはLIKE :keywordとし、値の側で'%' . $keyword . '%'のように%を連結します。
prepared statementで安全に検索する(SQLインジェクション対策)
検索値はユーザーが自由に入力するため、SQLインジェクションの主な侵入口になります。対策は他のCRUDと同じで、値はprepared statementのプレースホルダで渡すこと。文字列連結でSQLに直接埋め込まないのが鉄則です。
<?php
// ❌ 危険:検索値を直接SQLに連結
$keyword = $_GET['keyword'];
$sql = "SELECT * FROM items WHERE name LIKE '%{$keyword}%'";
$items = $pdo->query($sql)->fetchAll();
// keyword に ' OR '1'='1 などを入れられると全件漏洩のリスク <?php
// ◎ 安全:値はプレースホルダ経由
$stmt = $pdo->prepare('SELECT * FROM items WHERE name LIKE :keyword');
$stmt->execute([':keyword' => '%' . $keyword . '%']);
$items = $stmt->fetchAll(); prepared statementを使えば、入力値はSQLコマンドではなく「データ」として扱われるため、注入が成立しません。ただし——これだけでは、まだもう一段の穴が残っています。それが次のワイルドカードの問題です。プレースホルダの基本動作はCRUDの記事とPHP公式マニュアルでも確認できます。
ワイルドカード(%と_)をエスケープする
prepared statementはSQLインジェクションを防ぎますが、LIKEのワイルドカード自体は防ぎません。つまり、ユーザーが検索欄に%や_を入力すると、それがパターン記号として働いてしまいます。たとえば%だけを入力すると、全商品がヒットします。多くのチュートリアルが見落とすポイントです。
対策は、ユーザー入力に含まれる%・_、そしてエスケープ文字の\自体を、検索前にエスケープして「ただの文字」として扱わせることです。
<?php
// LIKE のワイルドカード(% と _)を文字として扱うためにエスケープする
function escapeLike(string $value): string {
// バックスラッシュ → % → _ の順に置換する
return str_replace(
['\\', '%', '_'],
['\\\\', '\\%', '\\_'],
$value
);
}
$raw = trim($_GET['keyword'] ?? '');
$keyword = escapeLike($raw);
$sql = 'SELECT * FROM items WHERE name LIKE :keyword ORDER BY id DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute([':keyword' => '%' . $keyword . '%']);
$items = $stmt->fetchAll(); MySQLのLIKEでは、標準でバックスラッシュ(\)がエスケープ文字として働きます。そのため\%と書くと「文字としての%」を意味します。escapeLike()を通すことで、50%OFFのような検索語も、記号ではなく文字列として正しく検索できるようになります。%で挟む処理は、このエスケープ済みの値に対して行います。
具体例で挙動を追ってみましょう。ユーザーが検索欄に50%と入力したとします。エスケープしない場合、SQLにはLIKE '%50%%'が渡り、末尾の%がワイルドカードとして働くため「50を含むすべて」がヒットしてしまいます。escapeLike()を通すと50の後ろの%が\%になり、LIKE '%50\%%'として「文字としての50%を含む」正しい検索になります。たった一文字の入力で結果が大きく変わるため、検索機能ではこのエスケープが効いているかを必ず確認します。
複数キーワードで絞り込む(AND検索)
「コーヒー 深煎り」のように、スペース区切りで複数の語を入力したら、そのすべてを含む商品に絞り込みたい——実用的な検索ではよくある要件です。キーワードを分割し、語の数だけLIKE条件をANDでつなぎます。
<?php
require 'db.php';
$raw = trim($_GET['keyword'] ?? '');
// 半角・全角スペースで分割(空要素は除く)
$words = preg_split('/[\s ]+/u', $raw, -1, PREG_SPLIT_NO_EMPTY);
$conditions = [];
$params = [];
foreach ($words as $i => $word) {
$conditions[] = "name LIKE :kw{$i}"; // プレースホルダ名は自前で生成
$params[":kw{$i}"] = '%' . escapeLike($word) . '%';
}
// 条件がなければ全件、あればANDで連結
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
$sql = "SELECT * FROM items {$where} ORDER BY id DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll(); ポイントは、プレースホルダ名(:kw0、:kw1…)をこちら側で生成していることです。ユーザー入力をプレースホルダ名にしているわけではないので安全です。値はすべてescapeLike()を通してからバインドします。ORに変えれば「いずれかを含む」検索になります。
検索結果が0件のときの処理
検索では「ヒットなし」も正常な結果です。0件のときに何も表示されないと、ユーザーは検索が失敗したのか結果がないのか分かりません。件数で表示を分岐し、メッセージを出します。
<?php
if ($items) {
foreach ($items as $item) {
echo htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8') . '<br>';
}
} else {
// 表示には「エスケープ前の元の入力」を使う
$safe = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
echo "「{$safe}」に一致する商品は見つかりませんでした";
} 画面に出すキーワードは、LIKE用にエスケープした$keywordではなく、元の入力$rawをhtmlspecialchars()して使います。エスケープ後の値(\%など)をそのまま表示すると、ユーザーには見覚えのない文字列になってしまうためです。
あわせて、ヒット件数を表示すると親切です。count($items)で取得件数が分かるので、「「コーヒー」の検索結果:3件」のように見出しに添えると、ユーザーは結果の量を一目で把握できます。検索結果の総件数(ページネーション用)が必要なら、LIMITを外したCOUNT(*)のクエリを別に実行して総数を取得します。
検索でやりがちな失敗
検索機能で起きやすい問題を、原因と対処でまとめます。動いているように見えて穴がある、というパターンが多い領域です。
| 症状 | 主な原因 | 対処 |
|---|---|---|
| 「%」を入れると全件ヒット | ワイルドカード未エスケープ | 入力をescapeLike()で処理する |
| プレースホルダが効かない | SQLに'%:keyword%'と記述 | SQLはLIKE :keyword、%は値側で連結 |
| 大文字小文字を区別したい/したくない | 照合順序(collation)の設定 | テーブルのcollation(_ci=区別しない等)を確認 |
| 件数が増えると遅い | LIKE前方一致以外はINDEXが効きにくい | 大規模なら全文検索(FULLTEXT)を検討 |
特に1番目のワイルドカード未エスケープは、SQLインジェクション対策(prepared statement)をしていても残る穴なので、検索機能では必ずセットで対応します。
動作確認:実際に動かして検証する
実装したら、実際に動かして想定通りに絞り込めるかを確認します。ローカル環境で、次の観点をひと通りチェックしてください。
- 部分一致:キーワードの一部を入れて、含む商品がヒットするか
- 0件:存在しない語を入れて「見つかりませんでした」が出るか
- ワイルドカード:検索欄に
%だけを入れて、全件ヒットしないか(エスケープが効いているか) - 複数キーワード:スペース区切りで2語入れて、両方を含む商品だけに絞られるか
- SQLインジェクション:
' OR '1'='1を入れて、全件漏洩しない(ヒット0または該当のみ)か - XSS:
<script>を含む語を入れて、画面でタグが実行されず文字として表示されるか
3番目のワイルドカード検証と5番目のインジェクション検証は、検索機能特有のチェックです。「正常な検索ができる」だけでなく、「攻撃的な入力で壊れない」ことまで確認して、はじめて実装完了と言えます。
次の一手:ページネーションと全文検索
検索ができたら、次は結果の見せ方と性能です。ヒット件数が多いと一覧が長くなりすぎるため、結果を分割するページネーション(LIMITとOFFSET)が次のステップになります。今回の検索条件にLIMITとOFFSETを足すだけで土台はできています。実装の骨格は次の通りです。
<?php
$perPage = 20;
$page = max(1, (int)($_GET['page'] ?? 1)); // 1未満は1に丸める
$offset = ($page - 1) * $perPage;
$sql = 'SELECT * FROM items WHERE name LIKE :keyword
ORDER BY id DESC
LIMIT :limit OFFSET :offset';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':keyword', '%' . $keyword . '%');
// LIMIT・OFFSET は整数として渡す必要がある
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$items = $stmt->fetchAll(); ここで一点ハマりどころがあります。LIMITとOFFSETは整数でなければならないため、execute()の配列渡しではなく、bindValue()でPDO::PARAM_INTを明示します。配列渡しだと値が文字列としてバインドされ、構文エラーになることがあるためです。ページ番号も(int)でキャストし、最小1に丸めておくと安全です。
さらに、データ件数が数万件を超えてLIKEの部分一致が遅くなってきたら、MySQLの全文検索(FULLTEXTインデックス)の導入を検討します。日本語の全文検索には形態素解析やN-gramの設定が関わるため、まずはLIKEで要件を満たし、規模が大きくなった段階で移行するのが現実的です。LIKEのパターンの詳細はMySQL公式マニュアルも参考になります。
検索結果に対して「ログインユーザーだけが操作できる」制御を加えたい場合はPHPログイン機能を、検索した商品の編集・削除まで作るならCRUDの実装を組み合わせると、実用的な管理画面に近づきます。
よくある質問
Q. LIKE検索とprepared statementを使えばSQLインジェクションは完全に防げますか?
SQLインジェクション自体はprepared statementで防げます。ただし、LIKEのワイルドカード(%・_)はSQLインジェクションとは別問題で、prepared statementでは無効化されません。検索機能では、prepared statementに加えてワイルドカードのエスケープも併用してください。
Q. 部分一致のLIKEはなぜ遅いと言われるのですか?
'%キーワード%'のように前後に%を付ける部分一致は、インデックスが効きにくいためです。前方一致('キーワード%')はインデックスを使えますが、部分一致は全行を走査する傾向があります。小〜中規模では問題になりにくいですが、大規模では全文検索の導入を検討します。
Q. 大文字小文字やひらがな・カタカナは区別されますか?
テーブルやカラムの照合順序(collation)に依存します。末尾が_ci(case-insensitive)の照合順序では大文字小文字を区別しません。意図した挙動になっているかは、検証時に実際の入力で確認してください。日本語のかな区別なども照合順序で変わります。
Q. 検索フォームはGETとPOSTどちらで送るべきですか?
検索は基本的にGETが適しています。検索結果のURLをそのまま共有・ブックマークでき、ページネーションとも相性が良いためです。一方、ログインや登録のように「状態を変える」操作はPOSTを使います。検索は状態を変えない取得操作なのでGETが自然です。
Q. 複数キーワードのAND検索とOR検索はどう使い分けますか?
「すべての語を含む」結果に絞りたいならAND、「いずれかの語を含む」幅広い結果が欲しいならORを使います。実装上は条件をimplode(' AND ', ...)でつなぐか' OR 'でつなぐかの違いです。一般的な絞り込み検索ではANDがよく使われます。
まとめ
PHPで検索機能を作る基本は、LIKEで部分一致を行い、検索値はprepared statementで渡し、ワイルドカード(%・_)はエスケープすることです。この2つをセットで対応すれば、SQLインジェクションと「%で全件ヒット」の両方を防げます。複数キーワードのAND検索や0件時の処理まで加えれば、実用的な検索が完成します。
まずは本記事のコードをローカルで動かし、検証チェックリスト——特にワイルドカードとインジェクションの項目——を確認してみてください。動いたら、ページネーションや全文検索へと発展させていけます。
検索やデータベースを含むWebアプリの実装・改修を相談したい場合は、Web制作・開発を手がけるRINIAにお問い合わせください。
