If you've ever asked Claude Code, Cursor, or Copilot to "add a function" to a PHP project, you've seen the output: untyped properties, mysql_query("SELECT * FROM users WHERE email = '$email'"), md5($password . SALT) for hashing, global $db everywhere, extract($_POST) because it's "convenient", and @file_get_contents($url) to silence the error you needed to see.
It's PHP 5 with a <?php header. The training data is half a decade of Stack Overflow answers from before strict types existed, before password_hash() was a function, before PSR-4 was a thing, and before anyone considered eval($_POST['code']) a problem.
Drop a CLAUDE.md at the root of your repo and the AI reads it before every task. Here are the five rules that fix the worst patterns — the full pack has 13.
Rule 1: declare(strict_types=1); at the top of every PHP file
PHP's default coercion is the source of an enormous class of "works in dev, breaks in prod" bugs. Without strict types, this passes:
function age(int $years): int {
return $years + 1;
}
age("7 dwarves"); // returns 8 — coerced silently
With declare(strict_types=1); as the first statement of the file, the same call throws TypeError and you find the bug at the call site instead of three layers down.
<?php
declare(strict_types=1);
It's per-file, has to be the first statement after <?php, and CI greps for files missing it. AI omits it by default because most snippets in its training data predate PHP 7's strict mode. The rule kills the reflex.
Rule 2: No raw SQL — PDO with prepared statements, always
Every PHP CVE-of-the-year list opens with SQL injection from interpolated strings. AI will write this without flinching:
$pdo->query("SELECT * FROM users WHERE email = '$email'");
$pdo->query("DELETE FROM orders WHERE status = '" . $status . "'");
Both are remote-code-execution by another name. The rule is: no string-interpolated SQL, ever. Use named parameters:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Configure PDO with ATTR_ERRMODE => ERRMODE_EXCEPTION, ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC, and crucially ATTR_EMULATE_PREPARES => false so prepared statements actually go to the server instead of being faked client-side. Identifiers (table and column names) cannot be parameterised — allowlist them in code, never accept them from input.
Rule 3: password_hash() for passwords, random_bytes() for tokens — never md5, sha1, or mt_rand
md5($password . SALT) is in every legacy PHP codebase, and AI keeps writing it because the training data is full of it.
// Banned
$hash = md5($password . SALT);
$token = md5(uniqid(mt_rand(), true));
password_hash() uses bcrypt (or Argon2, depending on PASSWORD_DEFAULT) with proper salting and a tunable work factor. random_bytes() is cryptographically secure. The mt_* family is predictable and unsafe for tokens, session IDs, password resets, or anything else security-sensitive.
$hash = password_hash($password, PASSWORD_DEFAULT);
if (!password_verify($input, $hash)) {
throw new InvalidCredentialsException();
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
// store new hash on next successful login
}
$token = bin2hex(random_bytes(32));
PASSWORD_DEFAULT upgrades automatically as PHP's recommended algorithm changes. password_needs_rehash() lets you migrate old hashes transparently on the next successful login. Never roll your own KDF. Never reach for crypt() directly.
Rule 4: No eval(), no extract(), no variable variables, no @-suppression
These four are remote-code-execution waiting to happen, and there is no legitimate use of any of them in modern PHP:
eval($code); // RCE
extract($_REQUEST); // mass-assignment of locals — sets $admin = true if ?admin=1
$$varName = $value; // variable variables — defeats static analysis
@file_get_contents($url); // hides errors you needed to see
extract($_REQUEST) is particularly dangerous because it silently creates $admin = true in the local scope if the request includes ?admin=1. AI loves this pattern because it makes templates "convenient". It also makes them exploitable.
The replacements are obvious once the bans are in place:
// Dynamic dispatch — match or a lookup, never eval
$handler = match ($type) {
'csv' => new CsvExporter(),
'json' => new JsonExporter(),
default => throw new InvalidArgumentException("unknown type: $type"),
};
// Parsing config — json_decode, never eval
$config = json_decode(
file_get_contents($path),
associative: true,
flags: JSON_THROW_ON_ERROR,
);
CI greps for \beval\s*\(, \bextract\s*\(, \$\$[a-zA-Z_], and the @ operator on function calls. Build fails on any hit. No exceptions for "templates", "the admin panel", or "just this once".
Rule 5: Constructor injection, not global $db
global $db; global $logger; global $mailer; at the top of every method makes code untestable, hides collaborators, and turns every function into a leaky abstraction. AI reaches for it because it's shorter.
// Before
class OrderService {
public function place(array $cart): int {
global $db, $logger, $mailer;
$db->insert(...);
$logger->info(...);
$mailer->send(...);
return $db->lastInsertId();
}
}
The fix is constructor injection with a real DI container (PHP-DI, Symfony DI, Laravel's container). Dependencies are explicit, the constructor is the contract, and tests pass mocks instead of monkey-patching globals.
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 on the properties enforces immutability — the compiler catches mutations instead of trusting reviewers. PSR-11's ContainerInterface is fine if you genuinely need the container itself, but inject specific dependencies wherever possible.
The other eight rules
The full Gist also covers:
- Rule 2: Type everything — properties, parameters, returns, including
voidandnever - Rule 5: Escape on output, not on input —
htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8') - Rule 6: PSR-4 autoloading via Composer — no
requirechains - Rule 7: PSR-12 enforced by
php-cs-fixer+phpstanat level 8 - Rule 8: Exceptions, not return codes — typed, hierarchical,
$previousalways passed - Rule 10:
matchoverswitch, named arguments over positional booleans, enums over magic strings - Rule 12: PHPUnit (or Pest) with real-DB feature tests, no DB mocks
- Rule 13: Configuration via env vars, secrets out of git,
.env.examplechecked in
Wrapping up
These rules don't replace the PHP manual or the PSR specs — they encode the failure modes AI repeats most often when generating PHP. Strict types, prepared statements, password_hash over md5, contextual escaping, PSR-4 autoloading, exceptions instead of return codes, constructor injection, match and enums, and a hard ban on eval/extract aren't style preferences. They're the line between a PHP app that ships and one that ends up in a CVE feed.
Drop the file at the root of your repo. The next AI prompt produces PHP your future self won't have to rewrite at 3am.
Free sample (this article + Gist): CLAUDE.md for PHP on GitHub Gist
Get the full CLAUDE.md Rules Pack — 35+ stacks, ready-to-drop files for every project:
- Solo licence — $27 — oliviacraftlat.gumroad.com/l/skdgt
- Team licence (up to 10 devs) — $79 — oliviacraftlat.gumroad.com/l/cnyyd
- Setup Sprint (we wire it into your repo) — $197

