From 96c520cfc8ec7d468813071f2872afd3049ed857 Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Thu, 1 Oct 2026 01:13:45 +0200 Subject: [PATCH 01/15] chore: add plugin manifests and repository metadata Claude Code and Cursor manifests with identical name, version, description, and author. The Cursor manifest declares the skills, agents, rules, and hooks paths. Adds .gitignore (eval results, local notes, tool caches) and .gitattributes (export-ignore for maintainer files). Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/plugin.json | 12 ++++++++++++ .cursor-plugin/plugin.json | 14 +++++++++++++ .gitattributes | 3 +++ .gitignore | 40 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 .cursor-plugin/plugin.json create mode 100644 .gitattributes create mode 100644 .gitignore diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..46db3c2 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "php-coding", + "version": "0.1.0", + "description": "Idiomatic PHP coding standards for AI assistants — PHP 8.4+, PER Coding Style, PHPStan level 8.", + "author": { + "name": "Cadasto B.V.", + "url": "https://github.com/Cadasto" + }, + "license": "MIT", + "repository": "https://github.com/Cadasto/php-coding-plugin", + "keywords": ["php", "coding-standards", "per", "phpstan", "phpunit"] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..40a09e2 --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "php-coding", + "version": "0.1.0", + "description": "Idiomatic PHP coding standards for AI assistants — PHP 8.4+, PER Coding Style, PHPStan level 8.", + "author": { + "name": "Cadasto B.V.", + "url": "https://github.com/Cadasto" + }, + "license": "MIT", + "skills": "skills", + "agents": "agents", + "rules": "rules", + "hooks": "hooks/cursor-hooks.json" +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..47aca82 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Exclude from `git archive` (and similar export) bundles — maintainer / repo infra, not runtime plugin UX +AGENTS.md export-ignore +.github/** export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc568c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Environment files +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +.claude/settings.local.json +*.swp +*.swo +*~ + +# Python (validation harness) +__pycache__/ +*.py[cod] + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp + +# Local working notes (planning/research kept out of the published plugin) +docs/plans/ +docs/research/ + +# Claude Code local project memory (machine-specific) +CLAUDE.local.md + +# PHP tool caches, if someone runs the reference config in a working copy +vendor/ +var/ +.php-cs-fixer.cache +.phpunit.cache + +# Plugin eval run output +evals/results/ From 5adfad5d9d4d8446b682fd044e9a30f43f053db2 Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Thu, 1 Oct 2026 01:13:45 +0200 Subject: [PATCH 02/15] feat: add reference php-cs-fixer, PHPStan, and Rector config @PER-CS plus declare_strict_types, PHPStan level 8 with phpVersion 80400, and an optional Rector config pinned to PHP 8.4 with no prepared sets. Co-Authored-By: Claude Opus 5.5 --- references/php-cs-fixer.php | 38 +++++++++++++++++++++++++++++++++++++ references/phpstan.neon | 6 ++++++ references/rector.php | 27 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 references/php-cs-fixer.php create mode 100644 references/phpstan.neon create mode 100644 references/rector.php diff --git a/references/php-cs-fixer.php b/references/php-cs-fixer.php new file mode 100644 index 0000000..ef340d9 --- /dev/null +++ b/references/php-cs-fixer.php @@ -0,0 +1,38 @@ +in(__DIR__) + ->exclude(['vendor', 'var']); + +return (new Config()) + ->setRules([ + '@PER-CS' => true, + 'declare_strict_types' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/references/phpstan.neon b/references/phpstan.neon new file mode 100644 index 0000000..12bec3a --- /dev/null +++ b/references/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 8 + phpVersion: 80400 + paths: + - src + - tests diff --git a/references/rector.php b/references/rector.php new file mode 100644 index 0000000..ba77eeb --- /dev/null +++ b/references/rector.php @@ -0,0 +1,27 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withPhpSets(php84: true) + ->withPhpVersion(PhpVersion::PHP_84); From 2666111dead400329f003cf7a419bbaa423f5bc6 Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Thu, 1 Oct 2026 01:13:45 +0200 Subject: [PATCH 03/15] feat(skills): add php-coding index with eleven references One knowledge skill: SKILL.md routes each change to the command to run and a reference to read. References cover style (PER 3.1), idioms (types, exceptions, 8.4 forms, 8.5 hints), testing (PHPUnit 12), security, compatibility, the PSR interfaces, Guzzle, Monolog, OpenTelemetry, Slim, and standalone Symfony components. Each rule cites a primary source. Co-Authored-By: Claude Opus 5.5 --- skills/php-coding/SKILL.md | 48 +++++++++++++ skills/php-coding/references/compatibility.md | 22 ++++++ skills/php-coding/references/guzzle.md | 57 +++++++++++++++ skills/php-coding/references/idioms.md | 71 +++++++++++++++++++ skills/php-coding/references/monolog.md | 32 +++++++++ skills/php-coding/references/opentelemetry.md | 59 +++++++++++++++ skills/php-coding/references/psr.md | 51 +++++++++++++ skills/php-coding/references/security.md | 51 +++++++++++++ skills/php-coding/references/slim.md | 40 +++++++++++ skills/php-coding/references/style.md | 39 ++++++++++ .../references/symfony-components.md | 66 +++++++++++++++++ skills/php-coding/references/testing.md | 29 ++++++++ 12 files changed, 565 insertions(+) create mode 100644 skills/php-coding/SKILL.md create mode 100644 skills/php-coding/references/compatibility.md create mode 100644 skills/php-coding/references/guzzle.md create mode 100644 skills/php-coding/references/idioms.md create mode 100644 skills/php-coding/references/monolog.md create mode 100644 skills/php-coding/references/opentelemetry.md create mode 100644 skills/php-coding/references/psr.md create mode 100644 skills/php-coding/references/security.md create mode 100644 skills/php-coding/references/slim.md create mode 100644 skills/php-coding/references/style.md create mode 100644 skills/php-coding/references/symfony-components.md create mode 100644 skills/php-coding/references/testing.md diff --git a/skills/php-coding/SKILL.md b/skills/php-coding/SKILL.md new file mode 100644 index 0000000..d154c31 --- /dev/null +++ b/skills/php-coding/SKILL.md @@ -0,0 +1,48 @@ +--- +name: php-coding +description: Use whenever a task writes, reviews, edits, refactors, or debugs PHP code, a .php file, a PHPUnit test, or composer.json dependencies, including a code review of pasted PHP, even when the user does not mention standards. PHP coding standards for PHP 8.4 and later, with 8.5 features as hints, PER Coding Style 3.1 through php-cs-fixer @PER-CS, PHPStan level 8, PHPUnit 12. It routes each change to the command to run and a reference to read, covering style, types and exceptions and modern idioms, PHPUnit testing, security, backward compatibility of a public API, the PSR interfaces (PSR-3, 6, 7, 11, 14, 15, 16, 17, 18, 20), the Guzzle HTTP client, Monolog logging, OpenTelemetry tracing and metrics, Slim 4 apps, and Symfony components used standalone (Console, Process, Yaml, HttpFoundation, EventDispatcher, Cache, Dotenv). Not for Laravel framework code (Laravel Boost covers it), full-stack Symfony configuration, WordPress, Drupal, or Pest. +--- + +# php-coding — PHP standards index + +- **Deterministic beats prose.** Whatever php-cs-fixer, PHPStan, PHPUnit, or `composer audit` fails the build for, run the tool. The references hold only what a green run can still ship, each with its source. +- **The pin is PHP 8.4.** Features that need 8.5 are hints until `composer.json` requires them. + +## Route the change + +Map every concern the change touches to a row below. Read each mapped reference before editing, and skip the rest. The files live in this skill's `references/` directory. + +| The change touches | Run | Read | +|---|---|---| +| layout, naming, line length, side effects in a declaring file, `switch` or closure or array formatting | `vendor/bin/php-cs-fixer fix --dry-run --diff` | [style.md](references/style.md) | +| a type, `mixed`, a union, `never`, an array shape, a generic, `==`, `throw` or `catch`, an enum, `readonly`, a date, `match`, an 8.4 or 8.5 feature | `vendor/bin/phpstan analyse` | [idioms.md](references/idioms.md) | +| a test, a data provider, a coverage attribute, a fixture, a guard that rejects input | `vendor/bin/phpunit` | [testing.md](references/testing.md) | +| a public or protected class, interface, method signature, property, or constant that other packages use | `vendor/bin/roave-backward-compatibility-check` | [compatibility.md](references/compatibility.md) | +| SQL, HTML output, passwords, tokens, `unserialize`, shell commands, file paths or URLs from input, XML, sessions | `composer audit` | [security.md](references/security.md) | +| a PSR interface, with or without the `Psr\` prefix in view: `ServerRequestInterface`, `ResponseInterface`, `MiddlewareInterface`, `RequestHandlerInterface`, `LoggerInterface`, `ContainerInterface`, `ClientInterface`, `ClockInterface`, a PSR-6 or PSR-16 cache, a PSR-14 dispatcher, a PSR-17 factory | `vendor/bin/phpstan analyse` | [psr.md](references/psr.md) | +| `GuzzleHttp\` | none | [guzzle.md](references/guzzle.md) | +| `Monolog\` | none | [monolog.md](references/monolog.md) | +| `OpenTelemetry\` | none | [opentelemetry.md](references/opentelemetry.md) | +| a Slim 4 app: `Slim\`, `AppFactory`, the PHP-DI bridge, route and middleware setup | none | [slim.md](references/slim.md) | +| a Symfony component used on its own: Console, Process, Yaml, HttpFoundation, EventDispatcher, Cache, Dotenv | none | [symfony-components.md](references/symfony-components.md) | +| `.php-cs-fixer.php`, `phpstan.neon`, `rector.php` | none | run `/php-lint-setup` | + +The session-start hook names the library references that match `composer.json`. A subagent does not inherit loaded skills, so an orchestrator puts this table, or the references to read, into every implementer and reviewer brief. + +## Minimum checklist + +Apply these even when no reference is read: + +- Run `vendor/bin/php-cs-fixer fix --dry-run --diff` and `vendor/bin/phpstan analyse`. Do not hand-apply a rule either tool decides. +- Every PHP file has `declare(strict_types=1);`. Compare with `===` and `!==`. +- Do not swallow an exception. Chain the previous one when you translate it. +- Bind SQL parameters. Escape output for the context it lands in. +- Every guard has a test that fails when the guard is removed. + +## Tie-breaks and review + +When both forms pass the tools, pick the form a reference cites and name the rule that decided it. "More idiomatic" alone is not a reason. + +For a focused review, dispatch the `php-reviewer` agent. It reports and never edits. If a workflow already owns the review seat, that reviewer reads these references itself; do not run `php-reviewer` beside it. + +Anything a person reads, such as a PR description or a review comment, goes in plain English, with the effect before the mechanism. diff --git a/skills/php-coding/references/compatibility.md b/skills/php-coding/references/compatibility.md new file mode 100644 index 0000000..7b7ef35 --- /dev/null +++ b/skills/php-coding/references/compatibility.md @@ -0,0 +1,22 @@ +# Compatibility — changing a public API + +Read when: a diff changes a public or protected class, interface, method signature, property, or constant in a package that other code depends on, or prepares a release. + +Deterministic backstop: `vendor/bin/roave-backward-compatibility-check` (`composer require --dev roave/backward-compatibility-check`). It compares the API of the last minor tag with `HEAD` and exits non-zero on a break. It needs SemVer `x.y.z` tags and, in CI, a checkout with the full history ([README](https://github.com/Roave/BackwardCompatibilityCheck)). PHPStan level 8 checks that callers match the new signature, not that existing callers and subclasses still work. + +The table-level rules below come from Symfony's backward compatibility promise, the most detailed published list for PHP ([BC promise](https://symfony.com/doc/current/contributing/code/bc.html)). + +## Rules + +- **A break in the public API needs a new major version.** SemVer item 8: the major version MUST be incremented for any backward incompatible change to the public API. Under `0.y.z` anything may change (item 4) ([SemVer 2.0.0](https://semver.org/spec/v2.0.0.html)). +- **An interface is frozen.** Adding a method breaks every implementer. So does adding an argument (with or without a default), removing one, or adding, removing, or changing a parameter or return type ([BC promise](https://symfony.com/doc/current/contributing/code/bc.html), "Changing Interfaces"). Ship a new interface instead. +- **A non-final class is nearly frozen.** Adding a public method is allowed. Removing a public or protected method, adding an argument even with a default, removing one, changing a parameter or return type, or making the class or a method `final` breaks code that extends or calls it. Most of these become allowed once the class or method is already final ([BC promise](https://symfony.com/doc/current/contributing/code/bc.html), "Changing Classes"). +- **Removing a public property breaks callers; removing a protected one breaks subclasses** ([BC promise](https://symfony.com/doc/current/contributing/code/bc.html)). +- **Deprecate, then remove in the next major.** Keep the old API working and deprecate it instead of removing it ([BC promise](https://symfony.com/doc/current/contributing/code/bc.html), quick reference). On the 8.4 pin, `#[\Deprecated]` makes the engine warn callers (see `idioms.md`). +- **Say what is not API.** Symfony excludes code tagged `@internal` from its promise. To close a class later, Symfony first adds `@final` so extenders get a warning, then switches to the native `final` keyword in the next major ([BC promise](https://symfony.com/doc/current/contributing/code/bc.html), note 6). Make a new class `final` from the start unless it is meant for extension. + +## Sources + +- Roave BackwardCompatibilityCheck README (8.22.0 on 2026-09-30) — +- Semantic Versioning 2.0.0, items 4 and 8 — +- Symfony backward compatibility promise — diff --git a/skills/php-coding/references/guzzle.md b/skills/php-coding/references/guzzle.md new file mode 100644 index 0000000..977621a --- /dev/null +++ b/skills/php-coding/references/guzzle.md @@ -0,0 +1,57 @@ +# Guzzle — HTTP client + +Read when: a diff requires `guzzlehttp/guzzle` or uses `GuzzleHttp\Client`, `GuzzleHttp\Exception\*`, `HandlerStack`, `Middleware::retry`, `MockHandler`, or a request option such as `timeout`, `base_uri`, `json`, `sink`, `stream`, or `verify`. + +Deterministic backstop: `vendor/bin/phpstan analyse`. Guzzle 8 declares array shapes for client config and request options, so stricter analysis can report an invalid option key or value type ([UPGRADING](https://github.com/guzzle/guzzle/blob/8.2.0/UPGRADING.md#generic-promise-and-structured-phpdoc-types)). Level 8 also fails `getResponse()` on a `RequestException`, which no longer has it. Nothing checks the rules below. + +This file targets **Guzzle 8** (`"php": "^7.4 || ^8.0"`). docs.guzzlephp.org may still show the Guzzle 7 manual; the Guzzle 8 manual is the `docs/` folder at the release tag. Check the constraint in `composer.json` first, because the 7.x line still receives patches and differs in the places listed under **On Guzzle 7**. Moving a repository from 7 means reading [UPGRADING](https://github.com/guzzle/guzzle/blob/8.2.0/UPGRADING.md) first. + +## Rules + +- **Set `timeout` on every client.** The total `timeout` defaults to `0`, which turns the deadline off. `connect_timeout` (cURL) and `read_timeout` (stream handler) default to 60 seconds ([timeout](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#timeout), [connect_timeout](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#connect_timeout)). On the cURL handlers only that deadline limits how long receiving headers and the body can take ([timeout phases](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#timeout-phases)). Pass `timeout` and `connect_timeout` to the `Client` constructor so they apply to every request ([quick start](https://github.com/guzzle/guzzle/blob/8.2.0/docs/quick-start.md#creating-a-client)). +- **Choose the catch by whether a response exists.** `NetworkException` means no response (`ConnectException`, `ConnectTimeoutException`, `NetworkTimeoutException`). `ResponseException` means a response exists, and only this branch has `getResponse()`. With `http_errors` on (the default), 4xx throws `ClientException` and 5xx throws `ServerException`. Anything else is a plain `RequestException` ([exceptions](https://github.com/guzzle/guzzle/blob/8.2.0/docs/exceptions.md)). Catch them in that order. Catching only `ConnectException` misses `NetworkTimeoutException`. A library that supports Guzzle 7 and 8 catches `Psr\Http\Client\NetworkExceptionInterface` ([UPGRADING](https://github.com/guzzle/guzzle/blob/8.2.0/UPGRADING.md#exception-hierarchy-and-classification)). For chaining the caught exception, see `idioms.md`. +- **`sendRequest()` returns 4xx, 5xx, and redirect responses.** `Client::sendRequest()` sets `http_errors` and `allow_redirects` to `false` for that call, whatever the client config says ([`Client.php`](https://github.com/guzzle/guzzle/blob/8.2.0/src/Client.php#L430-L439), [exceptions](https://github.com/guzzle/guzzle/blob/8.2.0/docs/exceptions.md)). Code typed to `Psr\Http\Client\ClientInterface` must check the status code itself. For the PSR-18 contract, see `psr.md`. +- **Build a stack with `HandlerStack::create()`.** It adds the `http_errors`, `allow_redirects`, `auth`, `cookies`, and `prepare_body` middleware ([`HandlerStack.php`](https://github.com/guzzle/guzzle/blob/8.2.0/src/HandlerStack.php#L56-L66)). `new HandlerStack($handler)` adds none of them, so the `http_errors`, redirect, and cookie options have no effect ([http_errors](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#http_errors), [handlers](https://github.com/guzzle/guzzle/blob/8.2.0/docs/handlers.md)). +- **End `base_uri` with `/` and start request paths without one.** Guzzle joins the two by RFC 3986 section 5.2. With `http://foo.com/foo/`, `bar` resolves to `/foo/bar` but `/bar` resolves to `/bar`. With `http://foo.com/foo`, even `bar` resolves to `/bar` ([quick start](https://github.com/guzzle/guzzle/blob/8.2.0/docs/quick-start.md#creating-a-client)). +- **Write HTTP method names in uppercase.** Guzzle 8 sends the method exactly as given, and only exact standard names such as `GET` and `POST` get method-specific handling. `request('get', …)` sends `get` ([UPGRADING](https://github.com/guzzle/guzzle/blob/8.2.0/UPGRADING.md#request-method-casing)). +- **Send bodies with `json`, `form_params`, or `multipart`, not a hand-encoded `body`.** `json` encodes with `JSON_THROW_ON_ERROR` ([`Client.php`](https://github.com/guzzle/guzzle/blob/8.2.0/src/Client.php#L1569-L1576)) and adds `Content-Type: application/json`. `form_params` adds `application/x-www-form-urlencoded`. Only one of the three can be used per request, and none of them together with `body` ([json](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#json), [form_params](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#form_params), [multipart](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#multipart)). `json` does not add `Accept` and takes no `json_encode()` flags. When you need either, set `body` and the headers yourself. +- **Stream large downloads.** The default `sink` is a PHP temp stream, and casting a PSR-7 body to string "could attempt to load a large amount of data into memory" ([sink](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#sink), [PSR-7](https://www.php-fig.org/psr/psr-7/)). To write to a file, use `'sink' => $path`. To read in chunks, use `'stream' => true` ([stream](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#stream)). When a streamed read stalls it throws `GuzzleHttp\Psr7\Exception\TimeoutException`, and when it fails it throws `\RuntimeException`. Neither extends `GuzzleException`, so catch them around the read loop ([read_timeout](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#read_timeout)). If you pass a `sink` resource you opened, close it yourself, because Guzzle 8 no longer does ([UPGRADING](https://github.com/guzzle/guzzle/blob/8.2.0/UPGRADING.md#sink-resource-ownership)). +- **Retry with `Middleware::retry()`, not a loop around `request()`.** Push it onto `HandlerStack::create()`. `http_errors` is the outermost middleware, so the decider receives a 429 as `$response` and a network failure as `$reason` ([retry](https://github.com/guzzle/guzzle/blob/8.2.0/docs/middleware.md#retry-middleware), [error messages](https://github.com/guzzle/guzzle/blob/8.2.0/docs/middleware.md#customizing-error-messages)). The middleware has no retry limit of its own, so the decider must return `false` after N attempts ([`RetryMiddleware.php`](https://github.com/guzzle/guzzle/blob/8.2.0/src/RetryMiddleware.php)). The manual's conservative policy retries only connection failures and 429. Without a delay callable, the wait doubles each time: 1 s, 2 s, 4 s. +- **Never set `verify` to `false`.** It defaults to `true`, which checks certificates against the system CA bundle. For a private CA, pass the bundle's path as a string ([verify](https://github.com/guzzle/guzzle/blob/8.2.0/docs/request-options.md#verify)). For TLS and secrets beyond this option, see `security.md`. +- **Keep credentials out of request logs and exception messages.** `Middleware::log()` with `MessageFormatter::DEBUG`, or with any template that includes headers, bodies, or URIs, can write credentials, cookies, and tokens. The manual says to avoid those templates in production unless the logs are protected or a processor redacts them ([logging middleware](https://github.com/guzzle/guzzle/blob/8.2.0/docs/middleware.md#logging-middleware)). `ClientException` and `ServerException` messages include a summary of the response body. To cap it, replace the default middleware with `Middleware::httpErrors(new BodySummarizer($bytes))` ([error messages](https://github.com/guzzle/guzzle/blob/8.2.0/docs/middleware.md#customizing-error-messages)). +- **Test with a `MockHandler` wrapped in `HandlerStack::create()`.** Wrapping it keeps `http_errors` and redirects in the path, the same as production. Push `Middleware::history($container)` to assert what was sent. When the mock's queue is empty, the next request throws `OutOfBoundsException` ([testing](https://github.com/guzzle/guzzle/blob/8.2.0/docs/testing-guzzle-clients.md)). Guzzle 8 rejects a per-request `handler` option, so pass the mocked client in through the constructor ([UPGRADING](https://github.com/guzzle/guzzle/blob/8.2.0/UPGRADING.md#per-request-handler-option)). For PHPUnit mechanics, see `testing.md`. + +## On Guzzle 7 + +- **The exception tree is smaller.** There is no `NetworkException` or `ResponseException`. `ConnectException` covers failures with no response, and `BadResponseException` (parent of `ClientException` and `ServerException`) is the branch whose `getResponse()` is never null ([7.15.5 exceptions](https://github.com/guzzle/guzzle/tree/7.15.5/src/Exception)). Catch `Psr\Http\Client\NetworkExceptionInterface` when the code must run on both majors. +- **Method casing, the per-request `handler` option, and `sink` ownership are Guzzle 8 changes.** On 7, `guzzlehttp/psr7` 2.x uppercases the method ([`Request.php`](https://github.com/guzzle/psr7/blob/2.8.0/src/Request.php)); the other rules above apply as written. + +```php +$stack = HandlerStack::create(); +$stack->push(Middleware::retry( + static function ( + int $retries, + RequestInterface $request, + ?ResponseInterface $response = null, + mixed $reason = null, + ): bool { + return $retries < 3 + && ($reason instanceof ConnectException || $response?->getStatusCode() === 429); + }, +)); + +$client = new Client([ + 'base_uri' => 'https://api.example.test/v2/', // then request 'users', not '/users' + 'handler' => $stack, + 'timeout' => 10.0, + 'connect_timeout' => 3.0, +]); +``` + +## Sources + +- Guzzle 8.2.0 manual (`docs/` at the tag): request options, exceptions, middleware, handlers, quick start, testing. +- Guzzle 7 to 8 upgrade guide. +- `Client::sendRequest`, `HandlerStack::create`, `RetryMiddleware` source at 8.2.0. +- Guzzle releases. +- PSR-7 `StreamInterface::__toString()`. diff --git a/skills/php-coding/references/idioms.md b/skills/php-coding/references/idioms.md new file mode 100644 index 0000000..19580c4 --- /dev/null +++ b/skills/php-coding/references/idioms.md @@ -0,0 +1,71 @@ +# Idioms — types, exceptions, and language forms on the 8.4 pin + +Read when: a diff adds or changes a type, `mixed`, a union, `never`, an array shape, a generic, a comparison, a `throw` or `catch`, an enum, `readonly`, a date, constructor promotion, `match`, or a PHP 8.4 or 8.5 feature. + +Deterministic backstop: `vendor/bin/phpstan analyse` at level 8 with `phpVersion: 80400`. Levels are cumulative. PHPStan does not check exception handling at all. + +## Contents + +- Types that PHPStan level 8 does not prove +- Exceptions +- Language forms on the 8.4 pin +- Newer in PHP 8.5 (hints) + +## Types that PHPStan level 8 does not prove + +Level 8 already fails missing typehints, wrong argument and return types, a method missing on some members of a union, and nullable access. With `phpVersion: 80400` it also reports an implicitly nullable parameter (`string $name = null`). Point those errors at the command; do not restate them. `@PER-CS` owns union and nullable layout. Leave `treatPhpDocTypesAsCertain` at its default (`true`); `false` relaxes checks. + +- **`declare(strict_types=1);` in every file.** Without it, a scalar parameter coerces instead of throwing `TypeError` ([type declarations](https://www.php.net/manual/en/language.types.declarations.php)). The reference fixer config adds `declare_strict_types`, which `@PER-CS` lacks. Run the fixer; do not hand-write the header. +- **`===` and `!==`.** `strict_types` does not change `==`, which converts operands first ([comparison](https://www.php.net/manual/en/language.operators.comparison.php)). Use `===` unless the conversion is the point of the line, and say so. Level 8 does not fail `==`. +- **`never` when the function does not return.** A function that always throws or exits is [`never`](https://www.php.net/manual/en/language.types.never.php), not `void`. Level 8 accepts `void` there. +- **No `mixed` to silence the analyzer.** Only level 9 restricts an explicit `mixed`. At level 8 it type-checks at the boundary and then goes dark. Write the real union or class. +- **An array that is a record has a shape.** `array{id: int, name: string}` for a record, `list` for a list. A bare `array` proves nothing ([PHPDoc types](https://phpstan.org/writing-php-code/phpdoc-types)). Put the shape in `@param` or `@return` next to the native `array`. +- **A function that hands the caller's type back is generic.** `@template T`, then `@param T` and `@return T`. Returning `mixed` drops the type ([PHPDoc basics](https://phpstan.org/writing-php-code/phpdocs-basics)). + +## Exceptions + +- **Chain the cause.** `Exception::__construct` takes `?Throwable $previous` as its third argument. When you catch one failure and throw another, pass it, or `getPrevious()` returns nothing ([constructor](https://www.php.net/manual/en/exception.construct.php)). +- **A `catch` that does nothing has handled the exception.** Execution continues and the caller never sees the failure ([exceptions](https://www.php.net/manual/en/language.exceptions.php)). An empty body, a log line with no rethrow, and a variable-less `catch` that continues are all swallowing. Handle it, chain and rethrow, or let it bubble. +- **Catch the type you can respond to**, specific before general. + - `LogicException` is an error "that should lead directly to a fix in your code" ([LogicException](https://www.php.net/manual/en/class.logicexception.php)). `InvalidArgumentException` and `DomainException` are the usual ones. Do not catch them and carry on. + - `RuntimeException` is an error "which can only be found on runtime" ([RuntimeException](https://www.php.net/manual/en/class.runtimeexception.php)): the database, the filesystem, the network. Translate it at the boundary into the caller's type, with `$previous` set. + - `Error` is for internal PHP errors ([Error](https://www.php.net/manual/en/class.error.php)). Catch `\Throwable` or `\Error` only at the process boundary, such as the global handler or the top of a worker loop. +- **Do not throw from `finally` unless that is the exception you mean to report.** If `try` and `finally` both throw, the `finally` one wins and the other becomes its previous ([exceptions](https://www.php.net/manual/en/language.exceptions.php)). + +## Language forms on the 8.4 pin + +Neither `@PER-CS` nor PHPStan level 8 enforces this table. A green build can ship the right-hand column. + +| Prefer | Over | Since | +|---|---|---| +| a backed or pure [`enum`](https://www.php.net/manual/en/language.enumerations.php) for a closed set | a class of constants, or string modes | 8.1 | +| [`readonly`](https://www.php.net/manual/en/language.oop5.properties.php) properties, or a `readonly` class | a value object written again after construction | 8.1 / 8.2 | +| [`DateTimeImmutable`](https://www.php.net/manual/en/class.datetimeimmutable.php) | `DateTime` for a value other code still holds; `DateTime` modifies itself | 5.5 | +| [constructor promotion](https://www.php.net/manual/en/language.oop5.decon.php) when the constructor only stores the argument | a parameter, a property, and an assignment | 8.0 | +| [`match`](https://www.php.net/manual/en/control-structures.match.php) when each arm is an expression and a missing arm should throw | a `switch` that only maps a value | 8.0 | +| `public private(set)` asymmetric visibility | a private property plus a getter that only returns it | 8.4 | +| a property hook that only computes or normalizes | a getter and setter pair doing the same | 8.4 | +| `array_find`, `array_any`, `array_all` | a `foreach` whose only job is to search | 8.4 | +| `#[\Deprecated]` so callers get the engine warning | a docblock `@deprecated` alone | 8.4 | + +The 8.4 rows cite the [PHP 8.4 release page](https://www.php.net/releases/8.4/en.php). Use `switch` when an arm needs statements. Do not promote a parameter the constructor must normalize first. Do not reach for a property hook when a method name is the API. Rector's PHP 8.4 set can strip the parentheses in `(new Foo())->bar()` that `@PER-CS` puts back; after any Rector run, the fixer runs and wins. + +## Newer in PHP 8.5 (hints) + +Not required while `composer.json` says `^8.4` and `phpVersion` is `80400`; PHPStan with that pin rejects the syntax, so do not hand-audit the grammar. Source for the table: . + +| Idiom (8.5) | Supersedes | +|---|---| +| `\|>` to chain a value through callables | nested calls that exist only to thread the value | +| `clone($object, ['field' => $value])` | a with-er that copies every property by hand | +| `Uri\Rfc3986\Uri` | `parse_url` on new code | +| `array_first` / `array_last` | a hand-rolled first or last that special-cases `[]` | +| `#[\NoDiscard]` on a return value that must not be ignored | a docblock warning the engine cannot see | + +The backtick operator, an alias of `shell_exec`, is deprecated in 8.5. Do not add new ones. Do not use features from an unreleased PHP version. + +## Sources + +- PHPStan rule levels and config — , +- PHP manual: type declarations, comparison, `never`, exceptions, exception classes — linked inline above +- PHP 8.4 and 8.5 release pages — , diff --git a/skills/php-coding/references/monolog.md b/skills/php-coding/references/monolog.md new file mode 100644 index 0000000..5a0fe53 --- /dev/null +++ b/skills/php-coding/references/monolog.md @@ -0,0 +1,32 @@ +# Monolog — PSR-3 logging + +Read when: a diff requires `monolog/monolog` or uses `Monolog\Logger`, `Monolog\Level`, `Monolog\LogRecord`, `Monolog\Handler\*`, `Monolog\Processor\*`, or `Monolog\Formatter\*`, or it wires logging for a long-running worker or a container. + +Deterministic backstop: `vendor/bin/phpstan analyse`. Monolog types `pushProcessor()` as `ProcessorInterface|(callable(LogRecord): LogRecord)`, so level 8 fails a Monolog 2 style processor that takes or returns an `array` ([`Logger.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Logger.php)). From level 3, PHPStan reports an assignment to a `readonly` property outside its class, such as `$record->message = …` ([rule](https://github.com/phpstan/phpstan-src/blob/2.2.16/src/Rules/Properties/ReadOnlyPropertyAssignRule.php)). Nothing checks the rules below. + +This file targets **Monolog 3** (`"php": ">=8.1"`, `psr/log: ^2.0 || ^3.0`, [releases](https://github.com/Seldaek/monolog/releases)). Monolog 2 used arrays for records and int constants for levels; the rules below do not apply to it. For the PSR-3 contract itself, including `{placeholder}` rules and the `exception` key, see `psr.md`. + +## Rules + +- **Type-hint `Psr\Log\LoggerInterface` and build `Monolog\Logger` only at the composition root.** Monolog implements PSR-3, which you can "type-hint against in your own libraries to keep a maximum of interoperability" ([README](https://github.com/Seldaek/monolog/blob/3.12.1/README.md)). Only the bootstrap or container code creates handlers, formatters, and processors. Application classes receive the interface. +- **Push `PsrLogMessageProcessor`, or `{placeholders}` stay in the text as written.** `Logger::addRecord()` stores the message exactly as given. Only `PsrLogMessageProcessor` replaces `{foo}` with `$context['foo']` ([processors](https://github.com/Seldaek/monolog/blob/3.12.1/doc/02-handlers-formatters-processors.md#processors), [`Logger.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Logger.php)). Push it once on the logger, and put values in `context` rather than joining them into the message. +- **Pass an exception as `context['exception']`, not as `$e->getMessage()`.** Formatters turn a `Throwable` in context into its class, message, code, `file:line`, and the whole `previous` chain ([`NormalizerFormatter.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Formatter/NormalizerFormatter.php), [`LineFormatter.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Formatter/LineFormatter.php)). `NewRelicHandler` and `RollbarHandler` read that key by name. `LineFormatter` and `JsonFormatter` leave out the stack trace until you call `includeStacktraces()`. `NormalizerFormatter::setMaxTraceLength()` (3.11) limits the number of frames ([CHANGELOG](https://github.com/Seldaek/monolog/blob/3.12.1/CHANGELOG.md)). +- **Use the `Level` enum.** Since 3.0, `Logger::DEBUG` through `Logger::EMERGENCY` are deprecated in favour of `Level::Debug` and the other cases. `Level::Warning->value` gives the old integer ([UPGRADE](https://github.com/Seldaek/monolog/blob/3.12.1/UPGRADE.md)). The reference `phpstan.neon` loads no deprecation rules, so the old constants pass. +- **A processor writes `extra`, or returns `$record->with(...)`.** In `LogRecord`, `datetime`, `channel`, `level`, `message`, and `context` are `readonly`. Only `extra` and `formatted` can be written ([`LogRecord.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/LogRecord.php)). To change the message or context, return `$record->with(context: $masked)`. Logger processors run once per record, and only if some handler will handle it ([`Logger.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Logger.php)). +- **Handler order is a stack, and `bubble: false` stops the records.** `pushHandler()` puts a handler on top, so the handler pushed last runs first. Handlers passed to the constructor run in array order. A handler with `bubble: false` keeps the records it handles from reaching the handlers below it. The manual's example: an error mailer with `bubble: false` means those errors never reach the file handler ([core concepts](https://github.com/Seldaek/monolog/blob/3.12.1/doc/01-usage.md#core-concepts)). +- **A logger with no handlers drops every record silently.** `addRecord()` just returns `false` ([`Logger.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Logger.php)). Create a channel with `$logger->withName('db')`, which returns a copy with a new channel name and the same handler and processor objects ([channels](https://github.com/Seldaek/monolog/blob/3.12.1/doc/01-usage.md#leveraging-channels)). +- **A failing handler throws out of the log call.** When a handler throws and no `setExceptionHandler()` closure is set, the `Logger` rethrows the exception into your code ([`Logger.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Logger.php)). Wrap network handlers in `WhatFailureGroupHandler` or `FallbackGroupHandler` when a logging outage must not stop the request ([wrappers](https://github.com/Seldaek/monolog/blob/3.12.1/doc/02-handlers-formatters-processors.md#wrappers--special-handlers)). +- **Give `FingersCrossedHandler` a `bufferSize`.** It holds every record until one reaches the activation level (`Level::Warning` by default), then sends the whole buffer to the handler it wraps. `bufferSize` defaults to `0`, which means no limit. With `stopBuffering: true` (the default), everything after activation goes straight through until the handler is reset ([`FingersCrossedHandler.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Handler/FingersCrossedHandler.php), [wrappers](https://github.com/Seldaek/monolog/blob/3.12.1/doc/02-handlers-formatters-processors.md#wrappers--special-handlers)). +- **In a long-running worker, call `$logger->reset()` after every job.** It flushes buffers and resets the handlers and processors that implement `ResettableInterface`. The manual calls this the equivalent of ending a web request ([long-running processes](https://github.com/Seldaek/monolog/blob/3.12.1/doc/01-usage.md#long-running-processes-and-avoiding-memory-leaks)). If you do not, `BufferHandler` flushes only from a shutdown function, which a worker reaches only when it exits, and by default its buffer has no limit (`bufferLimit: 0`) ([`BufferHandler.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Handler/BufferHandler.php)). Require `^3.12.1`. Before that release, once the loop guard had fired, the `Logger` dropped every record for the rest of the process ([CHANGELOG](https://github.com/Seldaek/monolog/blob/3.12.1/CHANGELOG.md)). +- **In a container, log to `php://stderr`.** `docker logs` shows the process's STDOUT and STDERR, not files the application writes ([Docker](https://docs.docker.com/engine/logging/)). Use `new StreamHandler('php://stderr', Level::Info)`. Under PHP-FPM, worker output goes to `/dev/null` unless the pool sets `catch_workers_output = yes`, which defaults to `no` ([FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php)). +- **Redact with `RedactingFormatter` (3.11+) on every handler that writes records out.** It wraps a handler's formatter and runs after all processors. It masks context and extra keys named `password`, `token`, `authorization`, `cookie`, and similar names, and also properties bound to `#[SensitiveParameter]` constructor parameters and any regex you configure, such as `RedactingFormatter::TOKEN_PATTERN` ([formatters](https://github.com/Seldaek/monolog/blob/3.12.1/doc/02-handlers-formatters-processors.md#formatters), [`RedactingFormatter.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Formatter/RedactingFormatter.php)). Formatters belong to one handler, so a handler without one writes secrets unmasked. A secret joined into the message is caught only when the same value also sits under a sensitive key or in a `#[SensitiveParameter]` property, or when a pattern matches it. For what counts as a secret, see `security.md`. +- **In tests, assert on `TestHandler` and fix the clock.** `TestHandler` records what it receives and has checks such as `hasRecordThatContains($message, Level::Error)` ([`TestHandler.php`](https://github.com/Seldaek/monolog/blob/3.12.1/src/Monolog/Handler/TestHandler.php)). Since 3.12, the `Logger` takes a PSR-20 clock through `$clock` or `setClock()` ([timestamps](https://github.com/Seldaek/monolog/blob/3.12.1/doc/01-usage.md#controlling-the-timestamp-of-records)). For PHPUnit mechanics, see `testing.md`. + +## Sources + +- Monolog 3.12.1 manual (`doc/` at the tag): usage, handlers, formatters, processors. +- Monolog source at 3.12.1: `Logger`, `LogRecord`, `FingersCrossedHandler`, `BufferHandler`, `RedactingFormatter`, `NormalizerFormatter`, `LineFormatter`, `TestHandler`. +- Monolog UPGRADE (3.0) and CHANGELOG. , +- PHPStan `ReadOnlyPropertyAssignRule` (level 3) at 2.2.16. +- Docker container logging. +- PHP-FPM `catch_workers_output`. diff --git a/skills/php-coding/references/opentelemetry.md b/skills/php-coding/references/opentelemetry.md new file mode 100644 index 0000000..022514d --- /dev/null +++ b/skills/php-coding/references/opentelemetry.md @@ -0,0 +1,59 @@ +# OpenTelemetry — traces, metrics, and logs on the 8.4 pin + +Read when: `composer.json` requires an `open-telemetry/*` package, or a diff uses `OpenTelemetry\API`, `OpenTelemetry\SDK`, `OpenTelemetry\Context`, `OpenTelemetry\SemConv`, `Globals::tracerProvider()`, `spanBuilder()`, `activate()`, `recordException()`, a propagator's `inject()` or `extract()`, or sets an `OTEL_*` variable. + +Deterministic backstop: none static. PHPStan level 8 does not see a span that never ends or a scope that never detaches. At runtime with assertions enabled, `DebugScope` raises an error with a backtrace for a scope left attached ([DebugScope](https://opentelemetry.io/docs/languages/php/context/#debugscope)), so leave `OTEL_PHP_DEBUG_SCOPES_DISABLED` unset in tests. + +Traces, metrics, and logs are all Stable for PHP ([status](https://opentelemetry.io/docs/languages/php/)). The `open-telemetry/*` packages and the `opentelemetry` extension all install on PHP 8.4. Release tags live in the read-only mirrors under `github.com/opentelemetry-php`, not in the monorepos. + +## Rules + +- **A library requires `open-telemetry/api`, never the SDK.** The spec says instrumented libraries "take a dependency only on the API packages" and the application decides whether to install the SDK ([overview](https://opentelemetry.io/docs/specs/otel/overview/#opentelemetry-client-architecture)). The PHP guide says to skip SDK setup when instrumenting a library and to depend only on API classes ([setup](https://opentelemetry.io/docs/languages/php/#setup), [instrumentation](https://opentelemetry.io/docs/languages/php/instrumentation/)). Only the application adds `open-telemetry/sdk` and an exporter. +- **Configure the application SDK through autoload and `OTEL_*` variables.** `OTEL_PHP_AUTOLOAD_ENABLED=true` builds and registers the SDK during Composer autoload from environment or `php.ini` values, and in `php.ini` booleans need quotes (`"true"`) ([autoloading](https://opentelemetry.io/docs/languages/php/sdk/#autoloading)). Autoload also registers `shutdown()` for every provider ([SdkAutoloader](https://github.com/opentelemetry-php/sdk/blob/1.15.0/SdkAutoloader.php)). Set `OTEL_SERVICE_NAME`; the guide calls `service.name` mandatory ([instrumentation](https://opentelemetry.io/docs/languages/php/instrumentation/#initialize-the-sdk)). +- **Know the defaults before you set one variable.** `OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, and `OTEL_LOGS_EXPORTER` all default to `otlp`, the protocol to `http/protobuf`, the endpoint to `http://localhost:4318`, and `OTEL_PROPAGATORS` to `tracecontext,baggage` ([Defaults](https://github.com/opentelemetry-php/sdk/blob/1.15.0/Common/Configuration/Defaults.php)). Set a signal you do not export to `none`, as the getting-started guide does ([getting started](https://opentelemetry.io/docs/languages/php/getting-started/)). +- **`OTEL_EXPORTER_OTLP_ENDPOINT` is a base URL.** Over HTTP the exporter appends `/v1/traces`, `/v1/metrics`, or `/v1/logs`. A full signal URL goes in `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` and friends, used as is ([endpoint URLs](https://opentelemetry.io/docs/specs/otel/protocol/exporter/#endpoint-urls-for-otlphttp), [SpanExporterFactory](https://github.com/opentelemetry-php/exporter-otlp/blob/1.4.0/SpanExporterFactory.php)). `grpc` also needs `open-telemetry/transport-grpc` and `ext-grpc` ([OTLP](https://opentelemetry.io/docs/languages/php/exporters/#otlp)). +- **A hand-built resource starts from `ResourceInfoFactory::defaultResource()`.** The default runs the detectors, including the one that reads `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES`, and you merge your attributes onto it ([resources](https://opentelemetry.io/docs/languages/php/resources/)). `emptyResource()` is empty ([ResourceInfoFactory](https://github.com/opentelemetry-php/sdk/blob/1.15.0/Resource/ResourceInfoFactory.php)), so the instrumentation page's `emptyResource()->merge(...)` example ignores those variables. +- **Zero-code instrumentation needs the extension and one package per library.** Installing the `opentelemetry` extension alone generates no traces; add the SDK, an exporter, and packages such as `open-telemetry/opentelemetry-auto-slim` ([auto-instrumentation](https://opentelemetry.io/docs/zero-code/php/auto/)). `open-telemetry/opentelemetry-auto-guzzle`, `-auto-psr18`, `-auto-psr15`, `-auto-slim`, and `-auto-pdo` (still pre-1.0) all require `ext-opentelemetry`. Client and app wiring: see `guzzle.md`, `slim.md`, `psr.md`. +- **Get the tracer where you use it, named for the instrumentation scope.** Pass the package or component name and its version to `getTracer()` ([Get a Tracer](https://opentelemetry.io/docs/specs/otel/trace/api/#get-a-tracer)); the PHP guide recommends calling `getTracer` when needed rather than exporting a tracer instance ([acquiring a tracer](https://opentelemetry.io/docs/languages/php/instrumentation/#acquiring-a-tracer)). With a hand-built SDK, a tracer taken from `Globals` before `buildAndRegisterGlobal()` runs comes from the no-op provider ([Globals](https://github.com/opentelemetry-php/api/blob/1.10.0/Globals.php)). An injected `TracerProviderInterface` is fine; the spec allows several providers for dependency injection ([TracerProvider](https://opentelemetry.io/docs/specs/otel/trace/api/#tracerprovider)). +- **End every span and detach every scope, in `finally`.** A span that is not ended "will not be exported" ([create spans](https://opentelemetry.io/docs/languages/php/instrumentation/#create-spans)), and you "must detach the active scope if you have activated it" ([nested spans](https://opentelemetry.io/docs/languages/php/instrumentation/#create-nested-spans)). `end()` does not deactivate the span ([End](https://opentelemetry.io/docs/specs/otel/trace/api/#end)). Detach, then end, as the repository example does ([getting_started.php](https://github.com/open-telemetry/opentelemetry-php/blob/main/examples/traces/getting_started.php)). +- **Activate a span that child spans or log records must see.** `startSpan()` does not make the span current. A new span takes its parent from the active context ([active context](https://opentelemetry.io/docs/languages/php/context/#active-context)), and log records carry the ids of the active span ([logging](https://opentelemetry.io/docs/languages/php/getting-started/#logging)). +- **Name the class of operation, not the instance.** "get_user" is a good span name and "get_user/314159" is not, "due to its high cardinality" ([Span](https://opentelemetry.io/docs/specs/otel/trace/api/#span)). Put the id in an attribute. Set attributes on the builder before `startSpan()`, because samplers only see what exists at creation ([SpanInterface](https://github.com/opentelemetry-php/api/blob/1.10.0/Trace/SpanInterface.php)). +- **Attribute keys come from the stable `open-telemetry/sem-conv` constants.** Use `OpenTelemetry\SemConv\Attributes\*`, such as `HttpAttributes::HTTP_REQUEST_METHOD`. `Incubating\*` holds experimental keys to use with caution, and `TraceAttributes` and `ResourceAttributes` are `@deprecated` ([sem-conv README](https://github.com/opentelemetry-php/sem-conv/blob/1.44.0/README.md)). Level 8 does not report deprecated constants; that takes [`phpstan/phpstan-deprecation-rules`](https://packagist.org/packages/phpstan/phpstan-deprecation-rules). +- **Record an exception only when it leaves the span, then set the status and rethrow.** Record the exception event "if and only if it remains unhandled when the span ends and causes the span status to be set to ERROR"; the spec template also sets `error.type` ([exceptions](https://opentelemetry.io/docs/specs/otel/trace/exceptions/#recording-an-exception)). `recordException()` does not set the status, and the SDK ignores both calls after `end()` ([Span](https://github.com/opentelemetry-php/sdk/blob/1.15.0/Trace/Span.php)). Do not copy the docs' `exception.escaped`, which semantic conventions deprecated ([registry](https://github.com/open-telemetry/semantic-conventions/blob/v1.44.0/model/exceptions/deprecated/registry-deprecated.yaml)); catching and chaining are in `idioms.md`. +- **Library code leaves the status unset on success.** "Instrumentation Libraries SHOULD NOT set the status code to Ok" unless configured to, and `Ok` is final ([Set Status](https://opentelemetry.io/docs/specs/otel/trace/api/#set-status)). Only the application sets `STATUS_OK`. + +```php +use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\SemConv\Attributes\CodeAttributes; +use OpenTelemetry\SemConv\Attributes\ErrorAttributes; + +$span = $tracer->spanBuilder('invoice.render') // class of operation, no ids + ->setAttribute(CodeAttributes::CODE_FUNCTION_NAME, __METHOD__) + ->startSpan(); +$scope = $span->activate(); // child spans and logs see it +try { + return $this->renderer->render($invoice); +} catch (\Throwable $e) { // record, then rethrow unchanged + $span->recordException($e); + $span->setAttribute(ErrorAttributes::ERROR_TYPE, $e::class); + $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage()); + throw $e; +} finally { + $scope->detach(); + $span->end(); +} +``` + +- **Propagate with `Globals::propagator()`, from the span you send.** `Globals::propagator()` is built from `OTEL_PROPAGATORS` ([PropagatorFactory](https://github.com/opentelemetry-php/sdk/blob/1.15.0/Propagation/PropagatorFactory.php)); `TraceContextPropagator::getInstance()` writes only `traceparent` and `tracestate`, so baggage is lost. `inject()` without a context reads the current one ([TraceContextPropagator](https://github.com/opentelemetry-php/api/blob/1.10.0/Trace/Propagation/TraceContextPropagator.php)), so activate the `KIND_CLIENT` span first or pass `$span->storeInContext(Context::getCurrent())`. The docs' outgoing example does neither and sends the parent's id ([manual propagation](https://opentelemetry.io/docs/languages/php/propagation/#manual-context-propagation)). +- **On the server, `extract()` the headers and pass the result to `setParent()`.** Prefer an instrumentation package when one covers the framework or client; the docs call them well tested and easier ([automatic propagation](https://opentelemetry.io/docs/languages/php/propagation/#automatic-context-propagation)). +- **Batch spans outside local debugging.** `SimpleSpanProcessor` forwards each span to the exporter as it ends, and `BatchSpanProcessor` sends them periodically ([span processor](https://opentelemetry.io/docs/languages/php/instrumentation/#span-processor)); autoload defaults `OTEL_PHP_TRACES_PROCESSOR` to `batch`. PHP exports block, so a per-span export delays the response ([export delays](https://opentelemetry.io/docs/languages/php/exporters/#minimizing-export-delays)). +- **`shutdown()` runs once, when the process ends.** It flushes queued telemetry ([shutdown](https://opentelemetry.io/docs/languages/php/instrumentation/#shutdown)); a hand-built SDK needs `setAutoShutdown(true)` or `ShutdownHandler::register()`. After it, the processor drops every new span ([BatchSpanProcessor](https://github.com/opentelemetry-php/sdk/blob/1.15.0/Trace/SpanProcessor/BatchSpanProcessor.php)), so never call it per request or per job. Code that calls `exit` or `die` inside a span leaves it unexported ([DebugScope](https://opentelemetry.io/docs/languages/php/context/#debugscope)). +- **A long-running worker holds telemetry until something flushes it.** The PHP batch processor has no timer; it exports when a batch fills, when a span ends after the scheduled delay, or on `forceFlush()` or `shutdown()` ([BatchSpanProcessor](https://github.com/opentelemetry-php/sdk/blob/1.15.0/Trace/SpanProcessor/BatchSpanProcessor.php)). Synchronous metrics leave only on `forceFlush()` or `shutdown()` ([metrics](https://opentelemetry.io/docs/languages/php/instrumentation/#synchronous-meters)). Call `forceFlush()` at a job boundary only if the process can be suspended or killed first; the spec says to call it only when "absolutely necessary" ([ForceFlush](https://opentelemetry.io/docs/specs/otel/trace/sdk/#forceflush-1)). +- **Logs reach OpenTelemetry through the logging library.** The OpenTelemetry logger "is not designed to be used directly" ([logs](https://opentelemetry.io/docs/languages/php/instrumentation/#logs)), and the `EventLogger` interfaces are `@deprecated` in `open-telemetry/api` 1.10.0. Use `open-telemetry/opentelemetry-logger-monolog` (`OpenTelemetry\Contrib\Logs\Monolog\Handler`); its records carry the active span's `trace_id` and `span_id` ([logging](https://opentelemetry.io/docs/languages/php/getting-started/#logging); handler setup: see `monolog.md`). For text logs, `open-telemetry/opentelemetry-auto-psr3` (pre-1.0) with `OTEL_PHP_PSR3_MODE=inject` adds both ids to the PSR-3 context ([README](https://github.com/opentelemetry-php/contrib-auto-psr3/blob/0.3.0/README.md)). + +## Sources + +- OpenTelemetry PHP docs: [index and status](https://opentelemetry.io/docs/languages/php/), [instrumentation](https://opentelemetry.io/docs/languages/php/instrumentation/), [SDK](https://opentelemetry.io/docs/languages/php/sdk/), [exporters](https://opentelemetry.io/docs/languages/php/exporters/), [context](https://opentelemetry.io/docs/languages/php/context/), [propagation](https://opentelemetry.io/docs/languages/php/propagation/), [resources](https://opentelemetry.io/docs/languages/php/resources/), [getting started](https://opentelemetry.io/docs/languages/php/getting-started/), [zero-code](https://opentelemetry.io/docs/zero-code/php/auto/) +- OpenTelemetry specification v1.61.0: [overview](https://opentelemetry.io/docs/specs/otel/overview/), [trace API](https://opentelemetry.io/docs/specs/otel/trace/api/), [trace SDK](https://opentelemetry.io/docs/specs/otel/trace/sdk/), [exceptions](https://opentelemetry.io/docs/specs/otel/trace/exceptions/), [OTLP exporter](https://opentelemetry.io/docs/specs/otel/protocol/exporter/) +- Release-tag sources: [`opentelemetry-php/api` 1.10.0](https://github.com/opentelemetry-php/api/tree/1.10.0), [`opentelemetry-php/sdk` 1.15.0](https://github.com/opentelemetry-php/sdk/tree/1.15.0), [`opentelemetry-php/exporter-otlp` 1.4.0](https://github.com/opentelemetry-php/exporter-otlp/tree/1.4.0), [`opentelemetry-php/sem-conv` 1.44.0](https://github.com/opentelemetry-php/sem-conv/tree/1.44.0), [`opentelemetry-php/contrib-auto-psr3` 0.3.0](https://github.com/opentelemetry-php/contrib-auto-psr3/tree/0.3.0) +- Packages: [Packagist `open-telemetry/`](https://packagist.org/packages/open-telemetry/), [PECL `opentelemetry`](https://pecl.php.net/package/opentelemetry). Versions read on 2026-09-30: api 1.10.0, sdk 1.15.0, context 1.5.0, exporter-otlp 1.4.0, sem-conv 1.44.0, logger-monolog 1.3.0, auto-psr3 0.3.0, extension 1.4.2. diff --git a/skills/php-coding/references/psr.md b/skills/php-coding/references/psr.md new file mode 100644 index 0000000..c42e71f --- /dev/null +++ b/skills/php-coding/references/psr.md @@ -0,0 +1,51 @@ +# PSR interface contracts + +Read when: `composer.json` requires a `psr/*` package (`psr/log`, `psr/cache`, `psr/simple-cache`, `psr/http-message`, `psr/http-factory`, `psr/http-client`, `psr/http-server-handler`, `psr/http-server-middleware`, `psr/container`, `psr/event-dispatcher`, `psr/clock`), or code type-hints a `Psr\Log`, `Psr\Cache`, `Psr\SimpleCache`, `Psr\Http\Message`, `Psr\Http\Client`, `Psr\Http\Server`, `Psr\Container`, `Psr\EventDispatcher`, or `Psr\Clock` interface. + +Deterministic backstop: none for these rules. `vendor/bin/phpstan analyse` at level 8 checks the interface signatures, not the contracts below. It does not report a discarded `withHeader()` result: PHPStan reports an unused call only when it has no impure points, and a method with no purity annotation counts as possibly impure. psr/http-message 2.0 has no such annotations ([rule](https://github.com/phpstan/phpstan-src/blob/2.2.16/src/Rules/Methods/CallToMethodStatementWithoutSideEffectsRule.php), [impure point](https://github.com/phpstan/phpstan-src/blob/2.2.16/src/Reflection/Callables/SimpleImpurePoint.php)). + +Every `psr/*` interface package installs on the 8.4 pin; `psr/http-message` 2.0 and `psr/log` 3.0 are the current majors. Monolog is `monolog.md`, Guzzle is `guzzle.md`, OpenTelemetry is `opentelemetry.md`, Symfony implementations are `symfony-components.md`, and Slim is `slim.md`. + +## Rules + +### HTTP messages (PSR-7, PSR-17, PSR-15, PSR-18) + +- **Keep the return value of every `with*()` call.** Messages are immutable. Every method that might change state MUST keep the current instance as it was and return an instance with the change (PSR-7 section 3, ). A bare `$response->withHeader(...);` statement does nothing. In middleware, pass the request that `withAttribute()` returned on to `$handler->handle()` (PSR-15 meta section 6.2, ). + + ```php + $response = $response->withHeader('Content-Type', 'application/json'); + return $handler->handle($request->withAttribute('user', $user)); + ``` + +- **Do not use `===` to test whether a message changed or was sent as passed.** A `with*()` method MAY return `$this` when the value does not change (PSR-7 meta, "New instances vs returning $this", ). A PSR-18 client MAY send a different object from the one given to `sendRequest()`, so the caller MUST NOT compare them by reference (). +- **Read a whole body with `(string) $body`, not `getContents()`.** `__toString()` MUST seek to the start and read to the end. `getContents()` returns only what remains after the current position (PSR-7 section 3.4). After a `write()` or an earlier read, `getContents()` returns a partial or empty string. If you need it, call `rewind()` first, which throws on a stream that cannot seek. +- **A body stream is shared, mutable state.** `StreamInterface` does not model immutability, and any code that holds the stream can move its cursor or change its contents. When in doubt, create a new stream and attach it with `withBody()` (PSR-7 section 1.3). Create that stream with `StreamFactoryInterface::createStream()` (). +- **Library code builds messages through PSR-17 factories.** Creating a concrete response ties a package to one PSR-7 implementation. PSR-17 exists so reusable middleware and handlers depend on the factory interfaces instead (PSR-17 meta section 2, ), and PSR-15 section 1.3 RECOMMENDS a response prototype or factory (). `ResponseFactoryInterface::createResponse()` defaults to `200`, so pass the status for an error response. +- **Type-hint `Psr\Http\Client\ClientInterface`, not a concrete client.** PSR-18 exists so libraries are decoupled from the client implementation and clients can be swapped (PSR-18, Goal). +- **A 4xx or 5xx response is not an exception.** A PSR-18 client MUST NOT throw for a well-formed response. Responses in the 400 and 500 range MUST be returned as normal (PSR-18, Error handling). A `catch (ClientExceptionInterface)` does not handle a failed call. Check `getStatusCode()`. The client throws `ClientExceptionInterface` only when it cannot send the request or parse the response. That is `RequestExceptionInterface` for a malformed request, and `NetworkExceptionInterface` for a network failure or timeout. +- **The exception-to-response middleware runs first.** PSR-15 section 1.4 RECOMMENDS a component that turns exceptions into responses, and says it SHOULD be the first component executed, wrapping all later processing. Which position runs first depends on the dispatcher. For Slim, see `slim.md`. + +### Logging (PSR-3) + +- **Put variable data in `$context` and reference it as `{key}`.** Placeholder names MUST match context keys and MUST use single braces with no inner whitespace, and they SHOULD use only `A-Z`, `a-z`, `0-9`, `_`, and `.`. Implementors MAY escape or translate through placeholders, and users SHOULD NOT pre-escape values (PSR-3 section 1.2, ). A value concatenated into the message string never reaches that handling. +- **Pass a caught exception as `$context['exception']`.** An `Exception` in context MUST be under the `'exception'` key, which lets the implementation extract a stack trace (PSR-3 section 1.3). `$logger->error($e->getMessage())` logs no trace. +- **Use the eight `Psr\Log\LogLevel` constants.** `log()` with a level the implementation does not know MUST throw `Psr\Log\InvalidArgumentException`, and users SHOULD NOT use a custom level (PSR-3 section 1.1). psr/log 3.0 leaves `$level` untyped, so PHPStan accepts any value. + +### Container, events, clock (PSR-11, PSR-14, PSR-20) + +- **Do not inject the container so a class can fetch its own dependencies.** Users SHOULD NOT do this. It is the service locator pattern (PSR-11 section 1.3, ). The meta document allows it only when the object computes the entry name from a variable set (a router fetching a controller), or in a factory behind an interface (PSR-11 meta section 4, ). Do not rely on two `get()` calls returning the same instance (section 1.1.2). +- **A listener returns `void`, and results go on the event.** The dispatcher MUST ignore listener return values and MUST return the same event object it was passed. An exception from a listener MUST stop later listeners and propagate to the emitter (PSR-14, ). +- **Read "now" from an injected `Psr\Clock\ClockInterface`.** PSR-20 exists because `time()` and `new \DateTimeImmutable('now')` make mocking the current time impossible (section 1.1, ). A test passes a frozen clock. PSR-20 leaves the timezone to the implementation, so call `setTimezone()` where it matters (meta section 4.2, ). Which date class to use is `idioms.md`. + +### Cache (PSR-6, PSR-16) + +- **Keys use `A-Z`, `a-z`, `0-9`, `_`, and `.`, at most 64 characters.** That is all an implementation MUST support, and `{}()/\@:` are reserved and MUST NOT be supported. An illegal key MUST throw `InvalidArgumentException` (PSR-6 Definitions, ; PSR-16 section 1.2, ). A URL, a path, or `user:42` type-checks and fails at runtime. Hash or encode it. +- **In PSR-16 a cached `null` looks like a miss.** A miss returns `$default`, so a stored `null` cannot be detected (PSR-16 section 1.2). PSR-6 `isHit()` tells "null found" from "not found" and SHOULD be checked on every `get()`. PSR-16 `has()` is for cache warming only, because it races with `get()`. +- **PSR-6 `set()` does not persist.** `CacheItemInterface::set()` sets the value on the item. `CacheItemPoolInterface::save()` persists it, and returns `false` on error instead of throwing (PSR-6 Interfaces). + +## Sources + +- PSR-3, PSR-6, PSR-7 (and meta), PSR-11 (and meta), PSR-14, PSR-15 (and meta), PSR-16, PSR-17 (and meta), PSR-18, PSR-20 (and meta): (raw text read from `php-fig/fig-standards` at `229d92c`, 2026-07-03) +- Package versions and PHP constraints, read 2026-09-30: `psr/http-message` 2.0, `psr/log` 3.0.2, `psr/cache` 3.0, `psr/simple-cache` 3.0, `psr/container` 2.0, `psr/http-factory` 1.1, `psr/http-client` 1.0, `psr/http-server-handler` and `psr/http-server-middleware` 1.0, `psr/event-dispatcher` 1.0, `psr/clock` 1.0. +- psr/log 3.0.2 `LoggerInterface::log()` signature: +- PHPStan 2.2.16 unused-call rule: diff --git a/skills/php-coding/references/security.md b/skills/php-coding/references/security.md new file mode 100644 index 0000000..b2e4fb8 --- /dev/null +++ b/skills/php-coding/references/security.md @@ -0,0 +1,51 @@ +# Security + +Read when: a diff builds SQL, writes a value into HTML, hashes or checks a password, makes a token or compares a secret, calls `unserialize()`, `exec()`, `shell_exec()`, `system()`, `passthru()`, or `proc_open()`, opens or includes a path built from input, parses XML, touches session setup or form handling, or fetches a URL taken from input. + +Deterministic backstop: `composer audit --locked` in CI. It checks the locked packages against security advisories and exits non-zero when one matches ([CLI](https://getcomposer.org/doc/03-cli.md#audit)). Since Composer 2.9, `update`, `require`, and `remove` also refuse a version with a known advisory, but `install` from a lock file does not, so keep the CI audit ([changelog](https://getcomposer.org/changelog/2.9.0-RC1), [config](https://getcomposer.org/doc/06-config.md#policy)). + +Nothing else here is deterministic. PHPStan level 8 checks types, not where a value came from, and has no taint analysis ([rule levels](https://phpstan.org/user-guide/rule-levels)). A green build can ship every rule below broken. + +## Rules + +- **SQL values go through bound parameters.** Use `PDO::prepare()` with `?` or `:name` placeholders and pass the values to `execute()`. A placeholder stands for a whole value, so write `LIKE ?` and bind `"%{$term}%"`, never `'%?%'` ([prepared statements](https://www.php.net/manual/en/pdo.prepared-statements.php)). Do not build the string with `PDO::quote()` or an escape function; the manual recommends `prepare()` instead and OWASP marks escaping "STRONGLY DISCOURAGED" ([PDO::quote](https://www.php.net/manual/en/pdo.quote.php), [SQL injection](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)). +- **Identifiers and sort direction cannot be bound, so map them from an allowlist.** For table names, column names, and `ASC`/`DESC`, OWASP says "input validation or query redesign is the most appropriate defense" ([SQL injection](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)). Put the value from your array into the SQL, never the input. + + ```php + $column = ['name' => 'u.name', 'created' => 'u.created_at'][$sort] ?? 'u.name'; + $direction = $descending ? 'DESC' : 'ASC'; + $stmt = $pdo->prepare("SELECT u.id, u.name FROM users u WHERE u.team_id = ? ORDER BY {$column} {$direction}"); + $stmt->execute([$teamId]); + ``` + +- **Escape HTML at output with `htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`.** Those flags are the default since PHP 8.1 (before that it was `ENT_COMPAT`, which leaves single quotes raw). Do not narrow them to `ENT_COMPAT` or `ENT_NOQUOTES`, and do not use `ENT_IGNORE`, which the manual says "may have security implications". Pass the charset, because an omitted one comes from `default_charset` ([htmlspecialchars](https://www.php.net/manual/en/function.htmlspecialchars.php)). +- **The output context decides the encoder.** Quote every attribute value. A value inside a URL is `rawurlencode()`d first, then HTML-escaped ([rawurlencode](https://www.php.net/manual/en/function.rawurlencode.php)). `htmlspecialchars()` translates only `& " ' < >`, so a whole URL taken from input needs its scheme checked (`https` or `http`) before it goes into `href` or `src`. Do not put input directly inside `