This file provides guidance to AI coding assistants (Claude Code, Cursor, and compatible tools that read AGENTS.md) working in this repository. It is the canonical instruction set. .claude/CLAUDE.md imports it with @../AGENTS.md, and any host-specific instruction file defers to it.
The PHP Coding Plugin is an AI plugin by Cadasto B.V. that teaches AI coding assistants idiomatic PHP: the PER rules a fixer does not settle, types, exceptions, modern idioms, PHPUnit, security, the PSR interfaces, and the libraries PHP services lean on (Guzzle, Monolog, OpenTelemetry, Slim, standalone Symfony components). It targets both Claude Code and Cursor from a single shared component set.
Current status: v0.2.0, released 2026-10-19. One knowledge skill,
php-coding, is an index over eleven reference files. Beside it are the/php-lint-setupcommand skill, the report-onlyphp-revieweragent, therules/php-context.mdcCursor rule,session-startandformat-on-savehooks, the reference lint config, and an eval suite. Everything passes./scripts/validate.sh. Do not assume a file is present because it is documented here. Check first.
Guidance is grounded in sources read on 2026-09-30, not in memory:
- Language pin — PHP 8.4. The Cadasto template-repo requires
"php": "^8.4". PHP 8.4 is in active support until 31 Dec 2026 (latest patch that day: 8.4.26). — https://www.php.net/supported-versions.php - Current stable — PHP 8.5.11 (24 Sep 2026). Features that need 8.5 are hints until a repo requires them. PHP 8.6 was a release candidate and is not a target. — https://www.php.net/releases/8_5_11.php
- PER Coding Style 3.1 (tagged 13 Aug 2026) is the living standard. It extends and replaces PSR-12, which stays the frozen baseline PER still accepts. — https://www.php-fig.org/per/coding-style/, https://github.com/php-fig/per-coding-style/blob/3.1.0/migration-3.1.md
- php-cs-fixer
@PER-CSaliases the newest set the fixer ships,@PER-CS3x0(PER 3.0), in php-cs-fixer 3.95.27. There is no 3.1 set yet, sostyle.mdlists the 3.1 additions to follow by hand. — https://cs.symfony.com/doc/ruleSets/PER-CS.html - PHPStan level 8 (cumulative; level 8 adds nullable access).
phpVersion: 80400is PHP 8.4. PHPStan has no taint analysis. — https://phpstan.org/user-guide/rule-levels - PHPUnit 12.5, the line the template-repo requires. PHPUnit 12.0 removed doc-comment metadata, so only attributes count. Pest is out of scope. — https://docs.phpunit.de/en/12.5/, https://github.com/sebastianbergmann/phpunit/releases/tag/12.0.0
- Compatibility: SemVer 2.0.0 and Symfony's backward compatibility promise define a break;
roave/backward-compatibility-check8.22.0 detects one. — https://symfony.com/doc/current/contributing/code/bc.html - Libraries (latest stable that day): Guzzle 8.2.0, with 7.15.x still patched; Monolog 3.12.1; OpenTelemetry PHP api 1.10.0 and sdk 1.15.0, with traces, metrics, and logs all stable; Slim 4.15.3, with no Slim 5 release; Symfony 8.1, whose components require PHP 8.4.1, and 7.4 LTS on PHP 8.2+;
psr/http-message2.0 andpsr/log3.0. Each reference cites its own release tag. - Skill format follows Anthropic's skill authoring best practices: an always-on description of at most 1,024 characters, a short SKILL.md, and reference files one level deep, with a table of contents past 100 lines. — https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
Decisions taken from the template-repo, and the ones this plugin does not copy:
- Kept: PHP
^8.4,declare(strict_types=1)on every file, PHPStan level 8, PHPUnit 12. - Not copied: PHPCS with a PSR-12 ruleset (
@PER-CSalready includes@PSR12).treatPhpDocTypesAsCertain: false(it relaxes checks). RectordeadCodeandcodeQualityas a default (Rector is optional, pinned to PHP 8.4, and the fixer runs after it).
Scope: framework-neutral PHP, the PSR interfaces, Guzzle, Monolog, OpenTelemetry, Slim, and Symfony components used standalone. Out of scope: Laravel framework code (Laravel Boost covers it), full-stack Symfony configuration, WordPress, Drupal, and Pest.
When a recommendation derives from a source, attribute it. A rule the fixer, PHPStan, or composer audit already fails the build for is a command, not a paragraph.
The facts above are copied into the README, the testing doc, the index description, the references, the agent, the Cursor rule, the session-start line, and the reference config comments. When one changes, re-read the source, then list every copy:
grep -rnE '8\.5\.[0-9]+|8\.4\.[0-9]+|current stable|PER Coding Style 3|PER-CS3x0|12\.5|Guzzle [78]|Monolog 3|Slim 4|Symfony [78]|2026-09-30' --exclude-dir=.git --exclude-dir=evals --exclude=CHANGELOG.md .Update each hit. Keep release numbers and "checked" dates in ## Sources sections and in this file, not in rule text. Leave released CHANGELOG sections as they are.
- Claude manifest:
.claude-plugin/plugin.json—name,version,description,author(an object{name, url}),license,repository,keywords. Claude Code discoversskills/,agents/, andhooks/automatically. - Cursor manifest:
.cursor-plugin/plugin.json— the same metadata plus explicit path keys (skills,rules,agents,hooks). NomcpServers. Keepname,version,description, andauthoridentical to the Claude manifest. - Knowledge skill:
skills/php-coding/SKILL.mdis the index: principles, a routing table from "what the change touches" to a command and a reference, and a minimum checklist. Its references areskills/php-coding/references/:style,idioms,testing,security,compatibility,psr,guzzle,monolog,opentelemetry,slim,symfony-components. - Command skill:
skills/php-lint-setup/SKILL.md(/php-lint-setup), withargument-hintandallowed-tools. It is separate because it is user-invoked. The legacycommands/folder is not used. - Agent:
agents/php-reviewer.md. Report-only, no sub-agent dispatch. Itstools:includeSkillso it can loadphp-coding. - Cursor rule:
rules/php-context.mdc(globs: ["**/*.php"]) mirrors the index table. - Claude hooks:
hooks/hooks.jsonwiresSessionStartandPostToolUse(matcher: "Write|Edit") to the shared scripts asbash "${CLAUDE_PLUGIN_ROOT}/hooks/<script>.sh". - Cursor hooks:
hooks/cursor-hooks.jsonwiressessionStartandafterFileEditto the same scripts with a workspace-relative command. - Shared hook scripts:
hooks/session-start.shdetectscomposer.jsonor a*.phpfile, prints one line, and names the library references whose packages appear incomposer.json(and Laravel Boost forlaravel/framework).hooks/format-on-save.shreads the edited path from the host's stdin JSON (tool_input.file_pathon Claude Code,file_pathon Cursor) and runsphp-cs-fixer fixon a*.phpfile when a project config and a fixer binary exist. Both always exit 0. - Reference lint config: top-level
references/php-cs-fixer.php,references/phpstan.neon,references/rector.php(Rector optional, default-off). This is a different directory fromskills/php-coding/references/. - Eval suite:
evals/<case>/withprompt.mdandgraders/, run byclaude plugin eval. Results go toevals/results/, which is gitignored. - Validation:
scripts/validate.shwraps the stdlib-onlyscripts/validate.py. CI (.github/workflows/validate.yml) installs Python 3 and runs it on pushes tomainand on pull requests. - Contributor docs:
docs/install.md,docs/testing.md,docs/versioning.md, anddocs/authoring.md(the detailed companion to this file). Local planning or research notes go indocs/plans/ordocs/research/, which are gitignored. - MCP config (not present): no
.mcp.json. Do not reference one.
No build step. The plugin is Markdown and JSON, plus reference PHP config, two shell hooks, and a stdlib-only Python validator.
./scripts/validate.sh # prints "OK: ..." or "FAIL: N problem(s)"; soft-skips if python3 is absent
claude plugin validate . # manifest and hooks; fix its warnings as well as its errors
claude --plugin-dir . # load this working copy for one session to dogfood it
claude plugin eval . --trust-plugin # run evals/ with and without the plugin; costs real model calls
bash hooks/session-start.sh # one standards line in a PHP workspace, nothing elsewhere; exit 0
printf '{"tool_input":{"file_path":"src/Foo.php"}}' | bash hooks/format-on-save.sh; echo $? # always 0scripts/validate.py checks the manifests and their parity, component paths, kebab-case names, hook JSON, and frontmatter. For the knowledge skill it also checks:
- the description fits 1,024 characters;
- every reference is linked from
SKILL.mdand every link resolves; - a reference past 100 lines has
## Contents; - the Cursor rule names every reference;
- the Cursor rule, the agents, and the references name only references that exist.
CI runs only the Python validator. claude plugin validate . and the eval suite are local steps. On Cursor, install via its plugin flow and confirm the skill, agent, and rule load. The full procedure is in docs/testing.md.
- All Markdown component files use YAML frontmatter, except reference files, which have none. Quote a value that contains
:or#, or use a>block (see Gotchas). - A reference file is
# Title, oneRead when:line, a backstop line naming the command or saying there is none,## Rules, and## Sources. Each rule is a bold lead plus an inline link to a primary source. Mention another reference by basename in backticks (see psr.md). - Use kebab-case for all directory and file names. A component's frontmatter
nameequals its directory (skills) or filename stem (agents). allowed-tools:pre-approves tools for a skill. Agents declaretools:instead (see Gotchas).- The index
descriptionis the only always-on text. It carries the trigger words for every reference and ends with what it is not for. - Use
"${CLAUDE_PLUGIN_ROOT}", in double quotes, for intra-plugin paths in Claude hook commands. Never hardcode absolute paths or~.
Update these in the same change. scripts/validate.py fails on the first three if they drift.
- The routing table in
skills/php-coding/SKILL.md, and itsdescriptionif the trigger words change. - The table in
rules/php-context.mdc. - Any
see <name>.mdmentions in other references and inagents/php-reviewer.md. - The session-start hook's package detection, for a library reference.
- An eval case under
evals/, with anllmgrader, atool_used: Skillgrader, and aregexgrader overtracethat matches aReadorGrepof the reference path, witharm: with-only(models often grep a reference instead of reading it). - README.md, this file, and CHANGELOG.md (under
## [Unreleased]once a release exists).
When a dated version fact changes, follow Refreshing a dated fact.
- Keep
version,description, andauthoridentical in both manifests.scripts/validate.pyfails on a mismatch. - Follow Semantic Versioning. The bump table and release steps are in
docs/versioning.md.
- Entries accumulate under
## [Unreleased]and fold into the next## [X.Y.Z] - YYYY-MM-DDsection at release. - Use the Keep a Changelog groups in order — Added, Changed, Deprecated, Removed, Fixed, Security — omitting empty groups.
- One terse line per bullet; lead with the subsystem. No rationale or PR links.
- Follow Conventional Commits, e.g.
feat(skills): add the slim reference. - Scopes:
skills,agents,hooks,rules,evals,docs, andreleaseforchore(release): vX.Y.Z. A scope is optional in Conventional Commits. Leave it out for a change outside these, such as the validator, CI, or the reference config. - Use feature branches and pull requests. CI validates every pull request and every push to
main.
- Agents use
tools:, notallowed-tools:. In an agent fileallowed-tools:is ignored and the agent silently inherits all tools.scripts/validate.pyfails onallowed-tools:and on a missingtools:. - A subagent does not inherit loaded skills.
php-reviewerlistsSkillintools:and loadsphp-coding:php-codingitself. An orchestrator puts the index table, or the references to read, into every brief. — https://code.claude.com/docs/en/sub-agents - Two directories are called
references/. The top-level one holds lint config that/php-lint-setupmirrors.skills/php-coding/references/holds the Markdown rules. Do not move one into the other. - Keep the two manifests in parity. Cursor needs explicit path keys. Claude relies on default-folder discovery.
- Quote
${CLAUDE_PLUGIN_ROOT}in Claude hook commands. A plugin path that contains a space splits into several words.claude plugin validate .warns on the unquoted form. - A Claude hook
timeoutis in seconds, not milliseconds. The default for a command hook is 600. — https://code.claude.com/docs/en/hooks - Hook scripts get the edited path from stdin. Claude Code sends it as
tool_input.file_pathand documents noCLAUDE_FILE_PATHvariable. Keep the stdin parsing informat-on-save.sh. - The Cursor hook uses a workspace-relative command (
bash hooks/session-start.sh), not${CLAUDE_PLUGIN_ROOT}. Cursor's plugin docs, checked 2026-09-30, do not say how a plugin hook path resolves. Confirm both hooks fire in a PHP workspace other than this repo. - An unquoted
:or#in a frontmatter value is invalid YAML (YAML 1.2.2, section 7.3.3).scripts/validate.pyis the only check that catches it;claude plugin validatepassed such a skill in a test on CLI 2.1.283. — https://yaml.org/spec/1.2.2/#733-plain-style - The fixer lags PER.
@PER-CSstill means PER 3.0. Do not claim the fixer enforces a 3.1 rule;style.mdlists them. AGENTS.mdand.github/are export-ignored in.gitattributes. A shipped skill, reference, agent, rule, or hook must not depend on them.- Do not restate a rule
@PER-CS, PHPStan level 8, orcomposer auditalready enforces. Name the command. A reference is only for what a green run can still ship. - Rector does not outrank the fixer. It is optional and default-off. If it runs, php-cs-fixer runs after it.
- Don't invent a companion MCP server. There is no
.mcp.jsontoday.