PHP×MySQLで検索機能を作る方法|LIKEであいまい検索を実装する


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ではなく、元の入力$rawhtmlspecialchars()して使います。エスケープ後の値(\%など)をそのまま表示すると、ユーザーには見覚えのない文字列になってしまうためです。

あわせて、ヒット件数を表示すると親切です。count($items)で取得件数が分かるので、「「コーヒー」の検索結果:3件」のように見出しに添えると、ユーザーは結果の量を一目で把握できます。検索結果の総件数(ページネーション用)が必要なら、LIMITを外したCOUNT(*)のクエリを別に実行して総数を取得します。


検索でやりがちな失敗

検索機能で起きやすい問題を、原因と対処でまとめます。動いているように見えて穴がある、というパターンが多い領域です。

症状主な原因対処
「%」を入れると全件ヒットワイルドカード未エスケープ入力をescapeLike()で処理する
プレースホルダが効かないSQLに'%:keyword%'と記述SQLはLIKE :keyword、%は値側で連結
大文字小文字を区別したい/したくない照合順序(collation)の設定テーブルのcollation(_ci=区別しない等)を確認
件数が増えると遅いLIKE前方一致以外はINDEXが効きにくい大規模なら全文検索(FULLTEXT)を検討

特に1番目のワイルドカード未エスケープは、SQLインジェクション対策(prepared statement)をしていても残る穴なので、検索機能では必ずセットで対応します。


動作確認:実際に動かして検証する

実装したら、実際に動かして想定通りに絞り込めるかを確認します。ローカル環境で、次の観点をひと通りチェックしてください。

  1. 部分一致:キーワードの一部を入れて、含む商品がヒットするか
  2. 0件:存在しない語を入れて「見つかりませんでした」が出るか
  3. ワイルドカード:検索欄に%だけを入れて、全件ヒットしないか(エスケープが効いているか)
  4. 複数キーワード:スペース区切りで2語入れて、両方を含む商品だけに絞られるか
  5. SQLインジェクション:' OR '1'='1を入れて、全件漏洩しない(ヒット0または該当のみ)か
  6. XSS:<script>を含む語を入れて、画面でタグが実行されず文字として表示されるか

3番目のワイルドカード検証と5番目のインジェクション検証は、検索機能特有のチェックです。「正常な検索ができる」だけでなく、「攻撃的な入力で壊れない」ことまで確認して、はじめて実装完了と言えます。


次の一手:ページネーションと全文検索

検索ができたら、次は結果の見せ方と性能です。ヒット件数が多いと一覧が長くなりすぎるため、結果を分割するページネーションLIMITOFFSET)が次のステップになります。今回の検索条件にLIMITOFFSETを足すだけで土台はできています。実装の骨格は次の通りです。

<?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();

ここで一点ハマりどころがあります。LIMITOFFSETは整数でなければならないため、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にお問い合わせください。