Write, debug, and review JavaScript with runtime-aware checks for correctness, compatibility, and performance.
Memory
PHP
Writes and reviews PHP while tracing runtime, data-boundary, dependency, testing, and deployment failures.
What it does
Write, debug, and review typed PHP across application code, Composer, PDO, sessions, CLI jobs, PHP-FPM, OPcache, tests, and static analysis. Guidance follows a configured minimum PHP version and applies checks for strict comparisons, bound SQL values, context-aware escaping, immutable dates, JSON exceptions, explicit timeouts, and streaming large data.
When to use it
- Diagnosing a blank page, silent 500, or hung request
- Reviewing PDO, JSON, session, upload, and date handling
- Planning a PHP 8.x upgrade and resolving deprecations
- Tuning PHP-FPM, OPcache, CLI workers, and cron jobs
The skill document
User preferences live in ~/Clawic/data/php/config.yaml (see Configuration); nothing else is stored on the user's machine. If you have data at an old location (~/php/ or ~/clawic/php/), move it to ~/Clawic/data/php/.
When To Use
- Writing or reviewing PHP — read Core Rules and Output Gates before emitting code
- Any error whose cause is not obvious in ten seconds: white page, silent 500, wrong value with no exception, a hang
- Runtime and deployment work: php.ini precedence, FPM pool sizing, OPcache, CLI workers, cron
- Dependency and upgrade work: Composer constraints, autoloading, deprecations, moving to a newer 8.x
- Data-boundary work: PDO queries, JSON payloads, uploads, sessions, dates, encodings
- Hardening: escaping per context, password hashing, deserialization, path containment, upload validation
- Not for framework internals —
laravelowns Eloquent, queues, Blade, and Laravel auth
Quick Reference
| Situation | Play |
|---|---|
== compares wrong, a numeric string misbehaves, floats do not add up | === everywhere; "0e12" == "0e34" is true → hash_equals → types.md |
| Keys vanish, order is wrong, a filtered list encodes as a JSON object | array_filter keeps keys; array_values() before encoding → arrays.md |
Accents break, strlen disagrees with what you see, a regex silently fails | mb_* for text, /u on every pattern, check preg_last_error() → strings.md |
Class design: traits, static::, readonly, enums, magic methods, clone | self:: binds at compile time, static:: at call time → oop.md |
| Exceptions, error handlers, warnings you cannot see, fatal errors | Catch \Throwable; fatals need register_shutdown_function → errors.md |
Injection, XSS, uploads, unserialize, passwords, tokens | Bind every value, escape per output context, random_bytes for secrets → security.md |
| Which PHP 8 feature to use, and how it bites | match is strict and exhaustive; named args make parameter names API → modern.md |
| Upgrading PHP, a deprecation wall, "which version can I still target" | Run the OLD version with deprecations as errors first → versions.md |
Class not found, lock conflicts, ^ vs ~, slow or huge installs | Commit the lock for apps, never for libraries → composer.md |
| White page, silent 500, "it worked yesterday", a hang with no output | Symptom → cause chains, plus the Xdebug 3 setup that actually works → debugging.md |
| Slow requests, high memory, "should we turn on JIT" | OPcache first, profile second; JIT does almost nothing for I/O-bound web → performance.md |
| A setting you changed has no effect; CLI and web disagree | php --ini per SAPI; .user.ini is cached for 300s → php-ini.md |
| 502, 504, "server reached pm.max_children", workers eating RAM | Size from measured RSS; slowlog names the hung function → fpm.md |
| Console scripts, daemons, cron, signals, exit codes | max_execution_time is 0 on CLI; recycle workers on a job count → cli.md |
| PHPUnit setup, flaky or falsely-passing tests, coverage, mocks | assertSame not assertEquals; static state leaks between tests → testing.md |
| PHPStan or Psalm errors, baselines, coding standard, automated refactors | Baseline the legacy, ratchet one level per merge → static-analysis.md |
| PDO connections, binding, transactions, "MySQL server has gone away" | charset=utf8mb4 in the DSN, EMULATE_PREPARES => false → database.md |
| Request/response handling, JSON bodies, uploads, outbound HTTP calls | JSON bodies live in php://input, not $_POST → http.md |
| Parallel requests from one user run one at a time; logins do not stick | session_start() holds an exclusive lock — session_write_close() early → sessions.md |
| Dates off by a day or an hour, DST, parsing, storage | DateTimeImmutable + IANA zones; createFromFormat inherits today → datetime.md |
json_decode returns null, big IDs lose digits, json_encode returns false | Always pass JSON_THROW_ON_ERROR → json.md |
| Reading or writing files, CSV, streams, temp files, permissions | Stream instead of slurping; write temp then rename() → files.md |
| Parallel work, queues, locks across processes, long-running runtimes | Share-nothing per request; proc_open deadlocks if you read one pipe → concurrency.md |
| Anything else | Core Rules below, then reproduce with php -d error_reporting=E_ALL -d display_errors=1 -r '' and add one thing back at a time |
Each file above is one sub-job and is self-contained: read SKILL.md by default, open exactly one guide when the situation matches.
Core Rules
declare(strict_types=1);on line 1 of every file. It is per-file and governs the CALLS made in that file, not the functions declared there — so an untyped legacy caller still passes"7"into yourintparameter. Return values are checked against the mode of the file where the function is DECLARED (types.md).===and!==by default;==only where both operand types are proven. Worked case:"0e12" == "0e34"istruebecause both are numeric strings equal to 0 — a hash compared with==accepts the wrong password. Compare digests withhash_equals($known, $given), which is also constant-time.- Check anything that can return
falsewith!== false, never with truthiness.strpos($haystack, $needle)returns0for a match at offset 0, andif (!strpos(...))reads that correct answer as a miss; the same shape breakspreg_match(0= no match,false= engine error) andfile_get_contents(""is a valid file). - Every SQL value is a bound parameter; every identifier comes from an allowlist you wrote. Placeholders cannot stand in for table or column names, and with the MySQL driver's default emulation the "prepared" statement is interpolated client-side — set
PDO::ATTR_EMULATE_PREPARES => false(database.md). - Escape at output, per context, never at input.
htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')covers HTML body and quoted attributes only; a value going into JavaScript needsjson_encodewith the HEX flags, one going into a URL needsrawurlencode, one going into a shell needsescapeshellarg(security.md). - Text is characters, not bytes:
strlen,substr,str_pad,strrev, anducfirstcount bytes and will cut a UTF-8 sequence in half. Usemb_strlen/mb_substr/mb_str_pad(php >=8.3) for anything a human reads; keep the byte functions for binary data, where they are the correct tool. DateTimeImmutableeverywhere.DateTime::modify()mutates the object that every reference shares, so a helper that "returns tomorrow" also moves the caller's date. The same call on an immutable returns a new instance and the bug cannot happen (datetime.md).- Every call that leaves the process gets an explicit timeout:
CURLOPT_TIMEOUTplusCURLOPT_CONNECTTIMEOUT, a stream contexttimeoutforfopen,PDO::ATTR_TIMEOUTfor connections.max_execution_timewill not save you — on Unix it does not count time spent inside system calls, so a hung socket read is only killed by FPM'srequest_terminate_timeout(fpm.md). - Stream instead of slurping.
file_get_contentson a 400 MB export needs 400 MB plus copies inside the default 128Mmemory_limit;fgetcsvin a loop, or a generator, holds one row. The same rule retiresfetchAll()on large result sets (files.md,performance.md).
Version Floors
The floors that shape everyday code. Individual guides carry more, inline in this same php >=X.Y form next to the instruction each one gates. Support windows and the upgrade procedure: versions.md.
| Feature | Needs |
|---|---|
Typed properties, arrow functions fn() =>, spread in calls, ??=, covariant return types | php >=7.4 |
match, named arguments, constructor promotion, ?->, union types, attributes, str_contains/str_starts_with/str_ends_with, static return type, throw as an expression, saner string-to-number comparison, PDO defaulting to exception error mode | php >=8.0 |
Enums, readonly properties, fibers, never, first-class callables f(...), new in initializers, array_is_list(), string-keyed array unpacking, overridable interface constants, htmlspecialchars escaping single quotes by default | php >=8.1 |
readonly classes, DNF types, null/false/true as standalone types, locale-independent strtolower/strtoupper, dynamic properties deprecated, ${var} interpolation deprecated | php >=8.2 |
Typed class constants, #[\Override], json_validate(), mb_str_pad(), readonly reinitialization inside __clone, stack-overflow detection via zend.max_allowed_stack_size | php >=8.3 |
Property hooks, asymmetric visibility, new Foo()->m() without parentheses, lazy objects, array_find/array_any/array_all, mb_trim, implicit nullable parameters (Foo $x = null) deprecated | php >=8.4 |
Pipe operator |>, #[\NoDiscard] | php >=8.5 |
Output Gates
Before delivering PHP code, check:
declare(strict_types=1);present, and every parameter, property, and return typed as narrowly as the value allows- No
==,!=, orswitchon values whose types are not both proven;in_arrayandarray_searchcarrytrueas the strict flag - Every SQL value bound, every identifier allowlisted,
EMULATE_PREPARESoff - Every value reaching HTML, an attribute, JavaScript, a URL, or a shell escaped with that context's function, at the point of output
- Nothing user-controlled reaches
unserialize,include/require,eval,extract, a filesystem path, or aheader()value without validation - Every outbound call (HTTP, DB, subprocess, lock) has a timeout, and every
false-returning call has its return inspected - Dates are
DateTimeImmutablewith an explicit IANA timezone; human-facing text handled withmb_* json_encode/json_decodecarryJSON_THROW_ON_ERROR- Syntax and functions stay within
min_php; layout matchesstyle; new behavior arrives with a test that was seen failing first
Configuration
User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/php/config.yaml. Never interview the user — record a preference the moment it is stated.
| Variable | Type | Default | Effect |
|---|---|---|---|
| min_php | 7.4 | 8.0 | 8.1 | 8.2 | 8.3 | 8.4 | 8.5 | 8.2 | Gates which Version Floors features may be emitted unguarded, and which fallback appears instead (versions.md) |
| sapi | fpm | cli | apache-mod | swoole | roadrunner | fpm | Assumptions about request lifetime, static state, timeouts, and where output goes (fpm.md, cli.md, concurrency.md) |
| framework | none | laravel | symfony | wordpress | slim | none | Whether to emit plain PHP or framework idiom, and when to hand off to a framework skill |
| strict_types | always | new-files | never | always | Whether emitted files open with declare(strict_types=1), and how coercion is discussed (types.md) |
| style | per-cs | psr-12 | custom | per-cs | Layout of emitted code and the fixer ruleset produced (static-analysis.md) |
| static_analysis | none | phpstan | psalm | phpstan | Which checker's annotations, config file, and baseline commands appear (static-analysis.md) |
| analysis_level | number (0-9) | 6 | Strictness assumed when annotating, and whether a docblock is required alongside a native type (static-analysis.md) |
| test_framework | phpunit | pest | none | phpunit | Shape of emitted tests, assertions, and data providers (testing.md) |
| db_layer | pdo | doctrine | eloquent | mysqli | pdo | Which data-access idiom every query example uses (database.md) |
Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied:
- Tooling: fixer (PHP-CS-Fixer vs PHP_CodeSniffer), Rector adoption, Xdebug vs pcov for coverage, profiler of choice, local runtime (Docker, Homebrew, Valet, WSL) — affects every emitted config block and command
- Conventions: namespace and directory layout, DTO style (arrays vs readonly classes vs enums), exception hierarchy, naming, docblock policy — affects generated code and review comments
- Platform: deployment target (FPM behind nginx, shared hosting, container, serverless, long-running runtime), OS, extension availability (
ext-intl,ext-mbstring,ext-pcntl), private Packagist — affectscomposer.md,php-ini.md,fpm.md - Safety posture: how aggressively to flag
unserialize, dynamicinclude,extract,@, missing timeouts, unpinned dependencies, and emulated prepares — affectssecurity.mdand review depth - Dependencies: banned or mandated packages (Guzzle vs Symfony HttpClient, Carbon vs native
DateTimeImmutable), tolerance for new transitive dependencies, stdlib-only constraints — affects every recommendation - Output format: whole file vs minimal diff, how much explanation, whether tests and a changelog entry accompany every change — affects the shape of the answer, never the correctness rules
- Work order: which gates run before a change is proposed rather than after — analyzer and suite green first, a plan approved before editing, a profile before any optimization — affects the sequence of every task, never the correctness rules
Traps
| Trap | Why it fails | Do instead |
|---|---|---|
if (!strpos($s, $x)) | Returns 0 for a match at position 0 — the correct answer reads as failure | strpos($s, $x) !== false, or str_contains() on php >=8.0 (strings.md) |
== to compare a password hash or token | "0e12" == "0e34" is true; both are numeric strings equal to zero | hash_equals(), or password_verify() for passwords (security.md) |
array_filter() then json_encode() | Keys survive, so the gap turns the array into a JSON object and the client's .map() fails | array_values() after every filter that feeds JSON (arrays.md, json.md) |
json_decode($s) with no flags | Returns null for the input "null" AND for a parse error — indistinguishable | Always JSON_THROW_ON_ERROR (json.md) |
$_POST for a JSON request body | Populated only for form-encoded and multipart bodies | json_decode(file_get_contents('php://input'), ...) (http.md) |
Trusting $_FILES['f']['type'] | The MIME type is supplied by the client and is trivially forged | finfo_file() on the temp file plus an extension allowlist (security.md) |
session_start() at the top and never closing | The session lock is held for the whole request, so one user's parallel requests execute one at a time | session_write_close() as soon as the last write is done (sessions.md) |
DateTime::createFromFormat('Y-m-d', $d) | Unspecified fields default to NOW, so the date carries the current time and can roll the day | Prefix the format with ! to zero them, then check getLastErrors() (datetime.md) |
@ to silence a warning | Hides the diagnosis and leaves the broken value in flight; on php >=8.0 it no longer even silences fatals | Handle the failure, or narrow error_reporting deliberately (errors.md) |
mt_rand/uniqid() for a token or password reset | Predictable: uniqid() is a formatted timestamp, mt_rand is a seeded PRNG | random_bytes()/random_int() (security.md) |
PDO::ATTR_PERSISTENT without cleanup | A worker inherits an open transaction, session variables, or temporary tables from the previous request | Non-persistent connections, or an explicit rollback on checkout (database.md) |
A closing ?> at the end of a PHP file | One trailing newline becomes output, and every later header() fails with "headers already sent" | Omit the closing tag in pure-PHP files (http.md) |
readfile() for a large download | Fills the output buffer with the whole file before sending | fpassthru with buffers flushed, or X-Accel-Redirect/X-Sendfile (http.md) |
opcache.validate_timestamps=0 with a deploy that only swaps files | Workers keep executing the previous release's bytecode | Reload FPM (kill -USR2) as the last deploy step (performance.md, fpm.md) |
SELECT then INSERT to avoid duplicates | Two workers pass the check in the same millisecond | Unique index plus catching the duplicate-key error (concurrency.md) |
Where Experts Disagree
- Arrays vs objects for structured data. The array-shape school keeps everything a
listand lets the analyzer verify it: no boilerplate, refactors are grep-based. The value-object school makes each shape a readonly class: constructor validation, no key typos, IDE completion. Boundary: data crossing a module or process boundary earns a class; a shape used in three lines of one function does not (static-analysis.md). - Active record vs data mapper. Eloquent-style records are faster to write and let any layer reach the database; Doctrine-style mappers keep the domain persistence-free and make the unit of work explicit. Boundary: how long the codebase must outlive its storage decisions — not how large it is (
database.md). - How strict to run the analyzer. One camp treats anything below
maxas unfinished; the other stops where the false-positive rate exceeds the bug-catch rate. Boundary: a greenfield typed domain reaches high levels cheaply; a decade-old codebase baselines and ratchets, and measures success as baseline shrinkage rather than level number (static-analysis.md). - Long-running runtimes. Swoole, RoadRunner, and FrankenPHP remove per-request bootstrap and can cut latency materially; they also delete PHP's strongest safety property, that every request starts from a clean process. Boundary: adopt when bootstrap is a measured share of p95 AND the team accepts hunting state leaks — never as a default (
concurrency.md). - Framework or none. A framework supplies routing, DI, migrations, and a hiring pool; plain PHP with a few PSR packages supplies a dependency tree you can read end to end. Boundary: the number of cross-cutting concerns you would otherwise hand-write — auth, queues, and migrations together is the tipping point.
Related Skills
More Clawic skills, get them at https://clawic.com/skills/php (install if the user confirms):
laravel— Eloquent, queues, Blade, and Laravel-specific authmysql— schema design, indexes, and query plans behind PDOnginx— the server in front of PHP-FPM: buffering, timeouts, static filesregex— PCRE pattern design; this skill'sstrings.mdcovers only the PHP bindingsdebugging— language-agnostic fault isolation; the PHP-specific version is this skill'sdebugging.md
Feedback
- If useful, star it: https://clawic.com/skills/php
- Latest version: https://clawic.com/skills/php
Part of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/php.
Questions people ask
- Can it diagnose silent PHP failures and deployment errors?
- Yes. It covers white pages, silent 500s, hangs, hidden warnings and fatals, php.ini precedence, PHP-FPM 502/504 responses, worker sizing, slow logs, and stale OPcache code.
- How does it handle database and security-sensitive code?
- PDO examples bind every SQL value, allowlist identifiers, disable emulated prepares, and use utf8mb4 in the DSN. Reviews also check output escaping by context, password hashing, secret generation, upload validation, deserialization, and path containment.
- Does it support PHP upgrades and quality tooling?
- Yes. It maps language features from PHP 7.4 through 8.5, respects a configured minimum version, and addresses Composer constraints, autoloading, deprecations, PHPUnit or Pest tests, PHPStan or Psalm, coding standards, and automated refactoring.
Related skills
Write, debug, and tune Java and JVM systems with JDK-aware code and diagnostic steps.
Write and debug strict TypeScript without `any`, unchecked casts, or unvalidated external data.
Write, debug, and review Python code across runtime, packaging, concurrency, testing, typing, and performance.
Build and review React code while diagnosing state, hook, rendering, hydration, and performance issues.
Design, debug, and harden GraphQL schemas, resolvers, clients, subscriptions, and federated graphs.