13 CLAUDE.md Rules That Make AI Write Modern PHP (Not PHP 5 Resurrected)

php dev.to

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 . "'");
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 void and never
  • 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 require chains
  • Rule 7: PSR-12 enforced by php-cs-fixer + phpstan at level 8
  • Rule 8: Exceptions, not return codes — typed, hierarchical, $previous always passed
  • Rule 10: match over switch, 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.example checked 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:

Source: dev.to

arrow_back Back to Tutorials