PHP — policy & pitfalls
Baseline PHP 8.x knowledge (enums, readonly, match, nullsafe, union/intersection/DNF types, named arguments, first-class callables, fibers, PSR-1/4/12, Composer) is assumed. This skill does not teach the language — it encodes project policy and the traps that keep appearing in code review.
Setup Check (run first)
Before writing non-trivial code:
- PHP version —
composer.json must declare "php": ">=8.4" (or higher). PHP 8.4 is the current active release; 8.3 is in security-only mode (active support ended Dec 2025); 8.2 has security support until Dec 2026 but should not be chosen for new projects. Check php --version if running locally.
declare(strict_types=1); at the top of every file. Non-negotiable — without it PHP coerces "42" → 42, 3.7 → 3, "abc" → 0 silently.
- Static analysis —
vendor/bin/phpstan (target level 8+) and/or vendor/bin/psalm. If missing, suggest composer require --dev phpstan/phpstan. Run before presenting code.
- Formatter —
vendor/bin/pint / php-cs-fixer / phpcs. Respect the existing config; don't introduce new violations.
- Lock file —
composer.lock must be committed for applications. Never commit vendor/.
- Framework — check for
laravel/framework, symfony/framework-bundle, etc. If present, stop and load the corresponding framework skill (laravel-engineer, etc.); this skill is pure PHP only.
MUST DO
- Strict types in every file —
declare(strict_types=1); on line 1 after <?php.
- Type everything — parameters, return types, property types. Untyped code is an error.
- Backed
enum for closed value sets — never class Status { const ACTIVE = 'active'; }.
readonly for immutable data — DTOs, value objects, events. Prefer readonly class (PHP 8.2+) over per-property readonly.
match over switch — strict comparison (===), exhaustive, returns value, throws UnhandledMatchError on miss.
- Named arguments for 3+ parameters —
new UserDto(id: 1, name: 'n', email: 'e') prevents silent argument swaps.
- Constructor property promotion — no separate property declarations + manual assignments.
DateTimeImmutable — never DateTime; the mutable one causes aliasing bugs.
- Prepared statements for every SQL query reaching user input. Bind with named or
? placeholders — never string-concat.
password_hash($p, PASSWORD_BCRYPT) / PASSWORD_ARGON2ID + password_verify. Never md5/sha1/sha256 for passwords.
- Parameterized logging via PSR-3 —
$logger->info('user.updated', ['id' => $id]), not string concat.
MUST NOT DO
- No
mixed without a PHPDoc comment explaining why — it disables type checking on the value.
- No untyped
array in public API — annotate with PHPDoc: /** @param list<User> $users */ or /** @return array<string, int> */.
- No raw
SimpleXMLElement / json_decode without validation — use json_validate() (PHP 8.3+) first, or JSON_THROW_ON_ERROR.
- No
(array) cast on objects to "export state" — it exposes private/protected with mangled keys ("\0*\0prop").
- No mutating a
readonly property through a reference — $ref = &$obj->prop throws Error on PHP 8.1+; PHP 8.3 also closed indirect bypass loopholes. Use clone or a with*() factory method to produce a modified copy.
- No
foreach ($arr as &$v) without unset($v) after — the reference leaks to the outer scope and a later assignment silently mutates the array.
- No
count($big) === 0 to check emptiness — use $big === [] or empty($big). count() on a Countable object can be expensive; count(null) throws TypeError on PHP 8.0+.
- No
!= / == — always !== / ===. 0 == "abc" was true until PHP 8, and string/array comparisons still surprise.
- No catching
\Throwable to silence errors — catch the specific exception type or let it propagate.
- No
global / service locator — constructor-inject everything.
- No credentials / PII in logs or error messages.
Reference Guide
| Load when |
File |
| Debugging subtle language / API behavior (enums, readonly, match, fibers, arrays, dates, comparison) |
references/pitfalls.md |
Designing type signatures (PHPStan generics, DNF, mixed/never/void, narrowing) |
references/types.md |
| Writing security-sensitive code (SQL, passwords, CSRF, file upload, serialization) |
references/security.md |
Output Format
When producing code:
- A short plan (1–3 bullets) of what's changing.
- The code with
declare(strict_types=1); and types everywhere.
- PHPStan passes at the project's configured level — mention if new
@phpstan-ignore / @param annotations were added and why.
When reviewing code: call out MUST-DO / MUST-NOT violations explicitly and suggest the minimal fix.
1---2name: php-engineer3description: Modern PHP 8.x policy and pitfalls. Use when writing, reviewing, or refactoring PHP code — enforces strict types, enum/readonly/match/DNF policy, PHPStan discipline, and catches the subtle traps (type coercion, mixed abuse, readonly mutation through references, enum serialization, fiber lifecycle, PDO emulation) that LLMs get wrong by default.4---56# PHP — policy & pitfalls78Baseline PHP 8.x knowledge (enums, readonly, match, nullsafe, union/intersection/DNF types, named arguments, first-class callables, fibers, PSR-1/4/12, Composer) is assumed. This skill does not teach the language — it encodes project policy and the traps that keep appearing in code review.910## Setup Check (run first)1112Before writing non-trivial code:13141. **PHP version** — `composer.json` must declare `"php": ">=8.4"` (or higher). PHP 8.4 is the current active release; 8.3 is in security-only mode (active support ended Dec 2025); 8.2 has security support until Dec 2026 but should not be chosen for new projects. Check `php --version` if running locally.152. **`declare(strict_types=1);`** at the top of every file. Non-negotiable — without it PHP coerces `"42"` → `42`, `3.7` → `3`, `"abc"` → `0` silently.163. **Static analysis** — `vendor/bin/phpstan` (target level 8+) and/or `vendor/bin/psalm`. If missing, suggest `composer require --dev phpstan/phpstan`. Run before presenting code.174. **Formatter** — `vendor/bin/pint` / `php-cs-fixer` / `phpcs`. Respect the existing config; don't introduce new violations.185. **Lock file** — `composer.lock` must be committed for applications. Never commit `vendor/`.196. **Framework** — check for `laravel/framework`, `symfony/framework-bundle`, etc. If present, **stop and load the corresponding framework skill** (`laravel-engineer`, etc.); this skill is pure PHP only.2021## MUST DO2223- **Strict types in every file** — `declare(strict_types=1);` on line 1 after `<?php`.24- **Type everything** — parameters, return types, property types. Untyped code is an error.25- **Backed `enum`** for closed value sets — never `class Status { const ACTIVE = 'active'; }`.26- **`readonly` for immutable data** — DTOs, value objects, events. Prefer `readonly class` (PHP 8.2+) over per-property `readonly`.27- **`match` over `switch`** — strict comparison (`===`), exhaustive, returns value, throws `UnhandledMatchError` on miss.28- **Named arguments for 3+ parameters** — `new UserDto(id: 1, name: 'n', email: 'e')` prevents silent argument swaps.29- **Constructor property promotion** — no separate property declarations + manual assignments.30- **`DateTimeImmutable`** — never `DateTime`; the mutable one causes aliasing bugs.31- **Prepared statements** for every SQL query reaching user input. Bind with named or `?` placeholders — never string-concat.32- **`password_hash($p, PASSWORD_BCRYPT)`** / `PASSWORD_ARGON2ID` + `password_verify`. Never `md5`/`sha1`/`sha256` for passwords.33- **Parameterized logging via PSR-3** — `$logger->info('user.updated', ['id' => $id])`, not string concat.3435## MUST NOT DO3637- **No `mixed` without a PHPDoc comment** explaining why — it disables type checking on the value.38- **No untyped `array` in public API** — annotate with PHPDoc: `/** @param list<User> $users */` or `/** @return array<string, int> */`.39- **No raw `SimpleXMLElement` / `json_decode` without validation** — use `json_validate()` (PHP 8.3+) first, or `JSON_THROW_ON_ERROR`.40- **No `(array)` cast on objects** to "export state" — it exposes private/protected with mangled keys (`"\0*\0prop"`).41- **No mutating a `readonly` property through a reference** — `$ref = &$obj->prop` throws `Error` on PHP 8.1+; PHP 8.3 also closed indirect bypass loopholes. Use `clone` or a `with*()` factory method to produce a modified copy.42- **No `foreach ($arr as &$v)` without `unset($v)` after** — the reference leaks to the outer scope and a later assignment silently mutates the array.43- **No `count($big) === 0` to check emptiness** — use `$big === []` or `empty($big)`. `count()` on a `Countable` object can be expensive; `count(null)` throws `TypeError` on PHP 8.0+.44- **No `!=` / `==`** — always `!==` / `===`. `0 == "abc"` was `true` until PHP 8, and string/array comparisons still surprise.45- **No catching `\Throwable`** to silence errors — catch the specific exception type or let it propagate.46- **No `global` / service locator** — constructor-inject everything.47- **No credentials / PII in logs or error messages.**4849## Reference Guide5051| Load when | File |52|---|---|53| Debugging subtle language / API behavior (enums, readonly, match, fibers, arrays, dates, comparison) | `references/pitfalls.md` |54| Designing type signatures (PHPStan generics, DNF, `mixed`/`never`/`void`, narrowing) | `references/types.md` |55| Writing security-sensitive code (SQL, passwords, CSRF, file upload, serialization) | `references/security.md` |5657## Output Format5859When producing code:60611. A short plan (1–3 bullets) of what's changing.622. The code with `declare(strict_types=1);` and types everywhere.633. PHPStan passes at the project's configured level — mention if new `@phpstan-ignore` / `@param` annotations were added and why.6465When reviewing code: call out MUST-DO / MUST-NOT violations explicitly and suggest the minimal fix.