Claude Code、Cursor、Copilot に「PHPプロジェクトへ関数を追加して」と頼んだことがあるなら、出力を見たことがあるはずです。無型プロパティ、mysql_query("SELECT * FROM users WHERE email = '$email'")、ハッシュ用のmd5($password . SALT)、いたるところにglobal $db、それが「便利」だからという理由でのextract($_POST)、そして、本当は見たかったエラーを黙らせるための@file_get_contents($url)です。
これはPHP 5で、<?phpヘッダがあります。学習データは、厳密型が存在する前、password_hash()が関数になる前、PSR-4が存在する前、そして誰もeval($_POST['code'])を問題だと考えていなかった頃の、Stack Overflowの回答を半世紀分(ではなく半...ではなく、直訳のニュアンスで)ではなく「半世紀分」ではなく「半世紀」ではなく、正しくは「半...?」—要するに約5年分です。
リポジトリのルートにCLAUDE.mdを置けば、AIは毎タスク実行前にそれを読み込みます。最悪のパターンを直す5つのルールを紹介します(完全版は13あります)。
ルール1:すべてのPHPファイルの先頭でdeclare(strict_types=1);
PHPのデフォルトの型強制(コーサージョン)が、「開発では動くのに、本番で壊れる」というバグの大量発生源です。厳密型がなければ、これが通ります:
function age(int $years): int {
return $years + 1;
}
age("7 dwarves"); // 8 を返す — 暗黙に強制される
ファイル先頭の最初の文としてdeclare(strict_types=1);を宣言すると、同じ呼び出しはTypeErrorを投げます。バグは、3階層下ではなく呼び出し箇所で見つかるようになります。
<?php
declare(strict_types=1);
これはファイル単位で、<?phpの直後に最初に書く必要があります。またCIは、それが欠けているファイルをgrepします。AIはデフォルトでは省略します。学習データにあるほとんどのスニペットが、PHP 7の厳密モードより前のものだからです。このルールが「反射的にやる」癖を断ちます。
ルール2:生のSQLは使わない — PDOのプリペアドステートメントを必ず使う
あらゆるPHPの「今年のCVE」リストは、補間文字列によるSQLインジェクションで開幕します。AIはためらわずにこれを書きます:
$pdo->query("SELECT * FROM users WHERE email = '$email'");
$pdo->query("DELETE FROM orders WHERE status = ' . $status . "'");
どちらも別名でリモートコード実行です。ルールはこうです:文字列補間されたSQLは、絶対に使わない。名前付きパラメータを使ってください:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
ATTR_ERRMODE => ERRMODE_EXCEPTION、ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC、そして重要なのがATTR_EMULATE_PREPARES => falseです。プリペアドステートメントが、クライアント側の偽装ではなく、本当にサーバへ送られるようにします。識別子(テーブル名やカラム名)はパラメータ化できません。コード内でホワイトリスト化し、入力として受け取らないでください。
ルール3:パスワードはpassword_hash()、トークンはrandom_bytes() — md5、sha1、mt_randは決して使わない
md5($password . SALT)は、あらゆるレガシーPHPコードベースに存在していて、AIもそれを書き続けます。学習データがそれでいっぱいだからです。
// 禁止
$hash = md5($password . SALT);
$token = md5(uniqid(mt_rand(), true));
password_hash()はbcrypt(またはPASSWORD_DEFAULTに応じてArgon2)を使い、適切にサルティングし、調整可能なワークファクタを備えています。random_bytes()は暗号学的に安全です。mt_*ファミリーは予測可能で、トークン、セッションID、パスワードリセット、その他のセキュリティに関わるものには危険です。
$hash = password_hash($password, PASSWORD_DEFAULT);
if (!password_verify($input, $hash)) {
throw new InvalidCredentialsException();
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
// 次回の成功したログインで新しいハッシュを保存する
}
$token = bin2hex(random_bytes(32));
PASSWORD_DEFAULTは、PHPが推奨するアルゴリズムが変わると自動的にアップグレードされます。password_needs_rehash()を使えば、次回の成功したログイン時に古いハッシュを透過的に移行できます。自前でKDFを組むことは決してしないでください。crypt()を直接呼ぶこともしないでください。
ルール4:eval()は禁止、extract()禁止、可変変数禁止、@による抑制禁止
この4つは、起きてしまうのを待つだけのリモートコード実行(RCE)であり、現代のPHPにはこれらのいずれにも正当な用途がありません。
eval($code); // RCE
extract($_REQUEST); // ローカルへの大量代入 — ?admin=1 があると $admin = true にする
$$varName = $value; // 可変変数 — 静的解析を無効化する
@file_get_contents($url); // 本当は見えていてほしいエラーを隠す
extract($_REQUEST) が特に危険なのは、リクエストに ?admin=1 が含まれていると、ローカルスコープでこっそりと $admin = true を作ってしまうからです。テンプレートを「便利」にするので、AI はこのパターンが大好きです。しかしそれは、同時に悪用可能にしてしまいます。
禁止が入れば、置き換えは明らかです:
// 動的ディスパッチ — eval ではなく、match で照合する(またはルックアップ)
$handler = match ($type) {
'csv' => new CsvExporter(),
'json' => new JsonExporter(),
default => throw new InvalidArgumentException("unknown type: $type"),
};
// 設定のパース — json_decode、eval ではなく
$config = json_decode(
file_get_contents($path),
associative:true,
flags:JSON_THROW_ON_ERROR,
);
CI は \beval\s*\(、\bextract\s*\(、\$\$[a-zA-Z_]、そして関数呼び出しに対する @ 演算子を grep します。ヒットが1つでもあればビルドは失敗します。「テンプレート」「管理画面」「とりあえず一回だけ」で例外はありません。
ルール 5:global $db ではなくコンストラクタ・インジェクション
すべてのメソッドの先頭で global $db; global $logger; global $mailer; を書くと、コードはテスト不能になり、協力者(依存関係)が隠れて、すべての関数が漏れやすい抽象になります。AI がそれに手を伸ばすのは、短いからです。
// Before
class OrderService {
public function place(array $cart): int {
global $db, $logger, $mailer;
$db->insert(...);
$logger->info(...);
$mailer->send(...);
return $db->lastInsertId();
}
}
修正は、実際のDIコンテナ(PHP-DI、Symfony DI、Laravel のコンテナ)を使ったコンストラクタ・インジェクションです。依存関係が明示され、コンストラクタが契約であり、テストではグローバルをサルいじりする代わりにモックが渡されます。
final class OrderService
{
public function __construct(
private readonly OrderRepository $orders,
private readonly LoggerInterface $logger,
private readonly MailerInterface $mailer,
) {}
public function place(Cart $cart): OrderId
{
$id = $this->orders->save($cart->toOrder());
$this->logger->info('order.placed', ['id' => (string) $id]);
$this->mailer->send(new OrderConfirmation($id));
return $id;
}
}
プロパティに付けた readonly は不変性を強制します――レビューアを信頼する代わりに、コンパイラがミューテーション(書き換え)を検出します。PSR-11 の ContainerInterface は、コンテナ自体を本当に必要とする場合は問題ありませんが、可能な限り特定の依存関係を注入してください。
残りの8つのルール
完全版のGistでは、さらに次も扱っています:
返却形式: {"translated": "翻訳されたHTML"}- ルール2:すべて記述する —
voidやneverを含め、プロパティ、パラメータ、戻り値をすべて型指定する - ルール5:入力ではなく出力でエスケープする —
htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8') - ルール6:ComposerによるPSR-4オートローディング —
requireの連鎖は不要 - ルール7:
php-cs-fixer+phpstan(レベル8)でPSR-12を強制する - ルール8:戻りコードではなく例外 — 型付きで階層構造、
$previousは常に渡す - ルール10:
switchよりmatch、位置引数のブール値より名前付き引数、マジック文字列より enum - ルール12:PHPUnit(またはPest)で実DBの機能テスト — DBモックは使わない
- ルール13:環境変数による設定、Gitからシークレットを排除、
.env.exampleをコミットする
まとめ
これらのルールはPHPマニュアルやPSR仕様の代わりではありません。PHPを生成する際にAIが最も頻繁に繰り返す失敗パターンを、コード化したものです。厳格な型、プリペアドステートメント、md5 ではなく password_hash、文脈に応じたエスケープ、PSR-4オートローディング、戻りコードではなく例外、コンストラクタ注入、match と enum、そして eval/extract の全面禁止は、単なるスタイルの好みではありません。これは、出荷できるPHPアプリと、CVEフィードに載ってしまうアプリを分ける境界線です。
リポジトリのルートにファイルを置いてください。次のAIのプロンプトが、未来のあなたが午前3時に書き直さなくて済むPHPを生成します。
無料サンプル(この記事+Gist): GitHub Gist上のPHP向け CLAUDE.md
CLAUDE.mdのルールパックを入手 — 35+のスタック、あらゆるプロジェクトにそのまま投入できるファイル:
- ソロライセンス — $27 — oliviacraftlat.gumroad.com/l/skdgt
- チームライセンス(最大10人の開発者)— $79 — oliviacraftlat.gumroad.com/l/cnyyd
- セットアップスプリント(リポジトリに組み込みます)— $197

