Language · PHP 8.5 · typed · PSR-12 · PHPStan max
PHP
Strict types, enums, readonly and constructor promotion.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou 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 — pinconfig.platform.phpincomposer.json. Never write for 7.x/8.0 idioms. - Composer 2.10 for dependency management and PSR-4 autoloading. Commit
composer.lock; deploy withcomposer install --no-dev --optimize-autoloader --classmap-authoritative. - PHPStan 2.2 at
level: maxwithphpstan/phpstan-strict-rulesandphpstan/phpstan-phpunit. Psalm 6 is an acceptable alternative but do not run both. - PHP-CS-Fixer 3.95 with the
@PER-CS3x0ruleset — PER Coding Style 3.0, PSR-12's successor, which codifies the 8.4 idioms this file promotes (asymmetric visibility, property hooks,newwithout parentheses, compound-type formatting). The dotted@PER-CS3.0/@PER-CS2.0aliases are deprecated; use@PER-CS3x0(or bare@PER-CS, which always tracks the latest). Keepdeclare_strict_types,global_namespace_import, andordered_importson. - 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,
readonlyclasses, the pipe operator|>,clone with, and the nativeUri\Rfc3986\Uri/Uri\WhatWg\Urlextension for parsing untrusted URLs.
Project conventions
declare(strict_types=1);is the first statement in every.phpfile, 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.php→final class Money). - PSR-4 autoloading only — no
require/includeof 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/(mirrorssrc/),bin/(executables),config/,public/(only web-served entry point — nothing else under docroot). - Naming:
PascalCasetypes,camelCasemethods/properties/variables,SCREAMING_SNAKE_CASEconstants, neversnake_caseidentifiers. Enum cases arePascalCase. - Imports: one
useper symbol, alphabetized, no leading backslash. Import functions/constants explicitly (use function strlen;) rather than fully-qualifying inline. - Run
php-cs-fixer fixandphpstan analysein 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$thisare typed too. - Prefer the narrowest type. Use union (
int|string) and nullable (?User) types instead of widening tomixed. Reach formixedonly at genuine serialization/reflection boundaries, and add a@param/@phpstan-varnarrowing it. - Use array shapes in PHPStan annotations, never a bare
array:@param array{id: int, name: string} $row. For collections, typelist<Order>orarray<string, Order>, notarray. mixed,object, and untypedarrayare 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, ) {} } readonlyclasses (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, }; } } matchoverswitch— expression-valued, strict (===), exhaustive, and it throws\UnhandledMatchErroron 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]); }newwithout 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 longisset()/is_null()ladders when??expresses it. - Model "no value" with
?Tand handle thenullbranch explicitly — never with a sentinel like-1,'', orfalse. - 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
newof collaborators inside methods, no service locators, noglobal, no static mutable state, noSingleton::getInstance(). Statics are for pure helpers only. - Program to interfaces for anything with an alternate implementation (clock, mailer, repository). Inject a
ClockInterfaceso time is testable — never calltime()/new DateTimeImmutable()deep in domain logic. - Use
DateTimeImmutable, neverDateTime(mutable, aliasing bugs).
Errors and exceptions
- Throw typed exceptions; never return
false/nullto 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 strictset_error_handlerthat converts warnings to\ErrorException. - Use
try/finally(or afinallyon atry/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 analyseatlevel: maxis a merge gate. Treat every error as a real defect. Do not paper over findings with@phpstan-ignoreor a growing baseline — fix the type. A new baseline entry needs a comment linking a tech-debt ticket.- Beyond
level: max, tighten withcheckImplicitMixed: trueandtreatPhpDocTypesAsCertain: false(trust native types, not PHPDoc, when deciding a condition is always true/false), plusreportUnmatchedIgnoredErrors: trueandphpstan-strict-rules(bans loose==, unsafenew static, etc.). - Run Rector with the
LevelSetListfor PHP 8.5 plusDEAD_CODEandCODE_QUALITYsets before manual review; commit its diff separately from behavior changes.
Testing
- PHPUnit 13 or Pest 4. Tests live in
tests/, classFooTest extends TestCase, methodstest_it_does_xor 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,failOnDeprecationinphpunit.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, andERRMODE_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 onpassword_needs_rehash. Nevermd5/sha1/cryptfor passwords. - Randomness:
random_int()/random_bytes()for anything security-relevant. Neverrand(),mt_rand(), oruniqid()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. Usejson_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, andsession_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, notparse_url(), to avoid host-confusion/SSRF bypasses.
Do
- Start every file with
declare(strict_types=1);. - Make classes
finalby 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 barestring/int). - Wrap multi-statement DB writes in a transaction with
try/finallyrollback.
Avoid
- Loose comparison
==/!=— always===/!==.match, notswitch. - Untyped params/returns/properties, and
mixed/arraywhere a shape or union is knowable. @suppression anderror_reporting()downgrades — enable exception flags instead.- Returning
false/null/-1to signal errors — throw a typed exception. global, static mutable state, singletons, and service locators — inject instead.newinside domain methods for collaborators;DateTime,time()in logic — inject a clock.- String-built SQL,
mysql_*/raw concatenatedmysqli,extract(),eval(), variable-variables$$x. md5/sha1for passwords,rand()/uniqid()for tokens,==on secrets.@phpstan-ignoreand 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, thenphpstan analyseat max, then the test suite. Report any residual errors — do not silence them with ignores or baselines. - When you add a dependency,
composer requireit (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,
.envfiles, or acomposer.lockchange 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.