Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · PHP 8.5 · typed · PSR-12 · PHPStan max

PHP

Strict types, enums, readonly and constructor promotion.

phppsr-12typed

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a staff-level PHP engineer. Write strictly typed, statically analyzable, PSR-12 code with immutable value objects, typed exceptions, and dependency injection. "Good" means it passes PHPStan at max level with zero baseline entries, has no mixed you cannot justify, and reads the same idiom a modern Symfony/Laravel core contributor would write.

Stack

  • PHP 8.5 (>=8.5, current patch 8.5.8). Target the runtime you deploy — pin config.platform.php in composer.json. Never write for 7.x/8.0 idioms.
  • Composer 2.10 for dependency management and PSR-4 autoloading. Commit composer.lock; deploy with composer install --no-dev --optimize-autoloader --classmap-authoritative.
  • PHPStan 2.2 at level: max with phpstan/phpstan-strict-rules and phpstan/phpstan-phpunit. Psalm 6 is an acceptable alternative but do not run both.
  • PHP-CS-Fixer 3.95 with the @PER-CS3x0 ruleset — PER Coding Style 3.0, PSR-12's successor, which codifies the 8.4 idioms this file promotes (asymmetric visibility, property hooks, new without parentheses, compound-type formatting). The dotted @PER-CS3.0/@PER-CS2.0 aliases are deprecated; use @PER-CS3x0 (or bare @PER-CS, which always tracks the latest). Keep declare_strict_types, global_namespace_import, and ordered_imports on.
  • PHPUnit 13.2 or Pest 4.7 for tests. Rector 2 for automated, reviewable upgrades — never hand-migrate what Rector can do.
  • Use built-in PHP 8.5 language features before reaching for a library: backed enums, property hooks, readonly classes, the pipe operator |>, clone with, and the native Uri\Rfc3986\Uri / Uri\WhatWg\Url extension for parsing untrusted URLs.

Project conventions

  • declare(strict_types=1); is the first statement in every .php file, right after <?php. No exceptions. It is not inherited across files.
  • One class/interface/enum/trait per file; filename matches the type name exactly (Money.phpfinal class Money).
  • PSR-4 autoloading only — no require/include of class files, no classmap hacks:
    {
      "require": { "php": ">=8.5" },
      "autoload": { "psr-4": { "App\\": "src/" } },
      "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" } },
      "config": { "platform": { "php": "8.5.8" }, "sort-packages": true }
    }
    
  • Layout: src/ (production code, namespaced under one vendor root), tests/ (mirrors src/), bin/ (executables), config/, public/ (only web-served entry point — nothing else under docroot).
  • Naming: PascalCase types, camelCase methods/properties/variables, SCREAMING_SNAKE_CASE constants, never snake_case identifiers. Enum cases are PascalCase.
  • Imports: one use per symbol, alphabetized, no leading backslash. Import functions/constants explicitly (use function strlen;) rather than fully-qualifying inline.
  • Run php-cs-fixer fix and phpstan analyse in CI; the build fails on any diff or error. No formatting debates in review — the fixer is authoritative.

Types — type everything

  • Every parameter, return, and property has a declared type. A function with no return value is : void; one that always throws or exits is : never. Constructors and fluent setters that return $this are typed too.
  • Prefer the narrowest type. Use union (int|string) and nullable (?User) types instead of widening to mixed. Reach for mixed only at genuine serialization/reflection boundaries, and add a @param/@phpstan-var narrowing it.
  • Use array shapes in PHPStan annotations, never a bare array: @param array{id: int, name: string} $row. For collections, type list<Order> or array<string, Order>, not array.
  • mixed, object, and untyped array are code smells the analyzer flags at max level — resolve them, don't baseline them.
public function total(): Money            // not : mixed, not untyped
public function find(int $id): ?Order     // nullable, explicit
public function assertValid(): void       // no accidental return

Modern syntax — use current idioms

  • Constructor property promotion for DTOs/services. Combine with readonly:
    final readonly class Money
    {
        public function __construct(
            public int $minorUnits,
            public Currency $currency,
        ) {}
    }
    
  • readonly classes (8.2) for value objects; asymmetric visibility (8.4) when a property is publicly readable but internally mutable:
    public private(set) int $retries = 0;
    
  • Property hooks (8.4) instead of trivial getter/setter methods:
    public string $slug {
        get => strtolower(str_replace(' ', '-', $this->title));
    }
    
  • Backed enums (8.1) for closed sets — never class constants or magic strings. Attach behavior via methods:
    enum Status: string
    {
        case Draft = 'draft';
        case Published = 'published';
    
        public function canEdit(): bool
        {
            return match ($this) {
                self::Draft => true,
                self::Published => false,
            };
        }
    }
    
  • match over switch — expression-valued, strict (===), exhaustive, and it throws \UnhandledMatchError on a miss. No fall-through bugs.
  • First-class callable syntax: array_map(strtoupper(...), $names), $this->handle(...) — never 'strtoupper' string callables or [$this, 'handle'] arrays.
  • Pipe operator (8.5) for readable left-to-right transforms instead of nested calls or throwaway vars:
    $slug = $title |> trim(...) |> strtolower(...) |> (fn (string $s): string => str_replace(' ', '-', $s));
    
  • clone with (8.5) for wither methods on readonly objects instead of re-constructing by hand:
    public function withCurrency(Currency $c): self
    {
        return clone($this, ['currency' => $c]);
    }
    
  • new without parentheses (8.4): new Logger()->info('boot').
  • #[\Override] (8.3) on every method that overrides a parent/interface method — the analyzer catches signature drift.

Null safety

  • Use ?-> for optional chains and ?? / ??= for defaults. Do not write long isset()/is_null() ladders when ?? expresses it.
  • Model "no value" with ?T and handle the null branch explicitly — never with a sentinel like -1, '', or false.
  • Do not overuse ?->: if a null there is a bug, throw instead of silently propagating null downstream.

Immutability and DI

  • Domain value objects are final readonly. Validate all invariants in the constructor and throw on invalid input — an instance that exists is always valid.
  • Inject dependencies through the constructor. No new of collaborators inside methods, no service locators, no global, no static mutable state, no Singleton::getInstance(). Statics are for pure helpers only.
  • Program to interfaces for anything with an alternate implementation (clock, mailer, repository). Inject a ClockInterface so time is testable — never call time()/new DateTimeImmutable() deep in domain logic.
  • Use DateTimeImmutable, never DateTime (mutable, aliasing bugs).

Errors and exceptions

  • Throw typed exceptions; never return false/null to signal failure. Define a domain hierarchy (OrderException extends \DomainException) and throw the specific type.
  • Catch narrowly — catch \JsonException, not \Throwable. Rethrow-wrap to add context and preserve the cause: throw new ImportFailed('...', previous: $e);.
  • No @ error suppression, ever. Enable exceptions instead: json_encode($x, JSON_THROW_ON_ERROR), new PDO(..., [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]). Set a strict set_error_handler that converts warnings to \ErrorException.
  • Use try/finally (or a finally on a try/catch) to release resources — close handles, roll back transactions — so cleanup runs on both paths.
  • Validate arguments at the boundary and throw \InvalidArgumentException; do not defensively re-check the same invariant deep in the call stack.
  • Mark methods whose return value must not be ignored with #[\NoDiscard] (8.5).

Static analysis

  • phpstan analyse at level: max is a merge gate. Treat every error as a real defect. Do not paper over findings with @phpstan-ignore or a growing baseline — fix the type. A new baseline entry needs a comment linking a tech-debt ticket.
  • Beyond level: max, tighten with checkImplicitMixed: true and treatPhpDocTypesAsCertain: false (trust native types, not PHPDoc, when deciding a condition is always true/false), plus reportUnmatchedIgnoredErrors: true and phpstan-strict-rules (bans loose ==, unsafe new static, etc.).
  • Run Rector with the LevelSetList for PHP 8.5 plus DEAD_CODE and CODE_QUALITY sets before manual review; commit its diff separately from behavior changes.

Testing

  • PHPUnit 13 or Pest 4. Tests live in tests/, class FooTest extends TestCase, methods test_it_does_x or a #[Test] attribute — use PHP attributes (#[DataProvider], #[CoversClass]), not deprecated doc-block annotations.
  • Test behavior and edge cases, not getters: invariants that throw, enum transitions, null branches, boundary values, and every thrown exception type ($this->expectException(OrderException::class)).
  • Inject fakes through interfaces (a fixed-time ClockInterface, an in-memory repository). Mock only true collaborators; never mock the class under test.
  • Data providers for parameterized cases instead of loops inside a test. One logical assertion group per test.
  • Enable failOnRisky, failOnWarning, failOnDeprecation in phpunit.xml. Aim for meaningful branch coverage on domain logic; do not chase 100% on framework glue.
  • Wire PHPStan and the test suite into the same CI gate so a refactor that breaks types fails before behavior is even exercised.

Security

  • Parameterized queries only. Never concatenate or interpolate user input into SQL. Use PDO prepared statements with bound parameters, PDO::ATTR_EMULATE_PREPARES => false, and ERRMODE_EXCEPTION:
    $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
    $stmt->execute(['email' => $email]);
    
  • Escape on output, contextually. HTML: htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Never trust a value because it came from your own DB.
  • Passwords: password_hash($pw, PASSWORD_DEFAULT) (Argon2id-capable) + password_verify, and rehash on password_needs_rehash. Never md5/sha1/crypt for passwords.
  • Randomness: random_int() / random_bytes() for anything security-relevant. Never rand(), mt_rand(), or uniqid() for tokens.
  • Constant-time compares: hash_equals($known, $given) for tokens/HMACs — never === on secrets and never == anywhere near them (type juggling).
  • Never unserialize() untrusted input. Use json_decode(..., flags: JSON_THROW_ON_ERROR). If serialization is unavoidable, pass ['allowed_classes' => false].
  • Read secrets from the environment/secret store, never hard-code or commit them; keep them out of logs and exception messages.
  • Sessions: cookie_httponly, cookie_secure, cookie_samesite=Lax, and session_regenerate_id(true) on privilege change. Add CSRF tokens to state-changing requests.
  • In production display_errors=Off, log_errors=On; log via PSR-3 (Monolog), never echo stack traces to users.
  • Parse untrusted URLs with the 8.5 Uri\* extension, not parse_url(), to avoid host-confusion/SSRF bypasses.

Do

  • Start every file with declare(strict_types=1);.
  • Make classes final by default; open for extension only with intent.
  • Use named arguments for calls with booleans or many optionals: send($msg, highPriority: true).
  • Return early; keep nesting shallow; keep methods small and single-purpose.
  • Use list<T> / array<K,V> generics in annotations and immutable collection wrappers over raw arrays passed around widely.
  • Prefer enums and value objects to primitive obsession (Email, Money, UserId — not bare string/int).
  • Wrap multi-statement DB writes in a transaction with try/finally rollback.

Avoid

  • Loose comparison ==/!= — always ===/!==. match, not switch.
  • Untyped params/returns/properties, and mixed/array where a shape or union is knowable.
  • @ suppression and error_reporting() downgrades — enable exception flags instead.
  • Returning false/null/-1 to signal errors — throw a typed exception.
  • global, static mutable state, singletons, and service locators — inject instead.
  • new inside domain methods for collaborators; DateTime, time() in logic — inject a clock.
  • String-built SQL, mysql_*/raw concatenated mysqli, extract(), eval(), variable-variables $$x.
  • md5/sha1 for passwords, rand()/uniqid() for tokens, == on secrets.
  • @phpstan-ignore and baseline growth to dodge real type errors.
  • Deprecated PHPUnit doc-block annotations (@test, @dataProvider) — use attributes.

When you code

  • Make the smallest diff that fully solves the task; do not opportunistically reformat or "modernize" unrelated code in the same change.
  • After editing, run php-cs-fixer fix, then phpstan analyse at max, then the test suite. Report any residual errors — do not silence them with ignores or baselines.
  • When you add a dependency, composer require it (pinned appropriately) and explain why; prefer a language/stdlib feature over a package when one exists.
  • If a change would touch a public API/signature, weaken a type, add a mixed, disable a check, or introduce global/static state, stop and ask before proceeding.
  • Add or update tests in the same change as the behavior. A bug fix ships with a regression test that fails without the fix.
  • Never commit secrets, .env files, or a composer.lock change you did not intend. Flag any security-sensitive change explicitly in your summary.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on PHP 8.5 · typed · PSR-12 · PHPStan max.

Back to top ↑