lts / php-qa-ci
Simple PHP QA pipeline and scripts. Largely just a collection of dependencies with configuration and scripts to run them together
Requires
- php: ^8.5
- composer-plugin-api: ^2.0
- ext-filter: *
- ext-json: *
- ext-openssl: *
- ext-tokenizer: *
- ext-xml: *
- nette/neon: ^3.4.8
- nikic/php-parser: ^5.8
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0.18
- phpstan/phpstan-strict-rules: ^2.0.12
- phpunit/phpunit: ^13.3
- symfony/console: ^8.1
- symfony/process: ^8.1
- symfony/service-contracts: ^3
- thecodingmachine/safe: ^3.4
- tomasvotruba/type-coverage: ^2.3
Requires (Dev)
- roave/security-advisories: dev-master
Suggests
- larastan/larastan: Laravel specific checks (PHPStan wrapper)
- phpstan/phpstan-doctrine: Adds checks for Doctrine
- phpstan/phpstan-mockery: Prevents Mockery from failing on Stan
- phpstan/phpstan-symfony: Symfony specific checks
Provides
None
Conflicts
None
Replaces
- dev-php8.5
- dev-chore/update-deps
- dev-feature/twig-decoupling-and-variadic-narrowing
- dev-php8.4
- dev-feature/dbf-rules-listing
- dev-feature/dbf-conformance-declaration
- dev-feature/dbf-infection-config
- dev-feature/dbf-config-templates
- dev-feature/dbf-project-record
- dev-feature/dbf-toolchain-conformance
- dev-chore/dbf-rule-documentation-and-identifier-index
- dev-feature/infection-diff-mode-qa
- dev-php8.3
This package is auto-updated.
Last update: 2026-09-13 07:55:43 UTC
README
A comprehensive quality assurance and continuous integration pipeline for PHP 8.5+ projects (this is the php8.5 branch; php8.4 and php8.3 branches support PHP 8.4 and 8.3). Runs tools in a logical order designed to fail as quickly as possible, suitable for both local development and CI.
This package is written for and tested on Linux.
Defence Before Fix
php-qa-ci is the PHP reference toolchain for Defence Before Fix,
and that is what the pipeline is for. Everything below — the rule bundle, the stable
identifiers, bin/rules, bin/rule-doc, bin/phpstan-rule, the justification lane — exists
to serve one method.
Defence Before Fix says that when you find a defect you do not start by fixing it. You treat the instance as evidence of a class, build an automated detector that catches the whole class, prove it fires on the original, sweep the codebase, fix every instance it finds, and only then make it blocking. Static analysis is the net; TDD is the filter. The net means the class can never silently return; the filter means this instance is genuinely fixed rather than silenced.
The method is specified separately from this tool — same authorship, deliberately
separate artefacts, versioned independently of each other and of php-qa-ci — in three
documents: a method specification, a
detector specification and a
toolchain specification. The site
also maintains a register of tools graded
clause by clause — PHPStan, Psalm, ESLint, Semgrep, CodeQL and the rest, with
php-qa-ci held to the same scrutiny
and currently failing clauses it names. A specification you can fail is worth more than one
you wrote to pass.
All three specifications are vendored into this package under
remote-docs/defence-before-fix.github.io/
with provenance, so a consuming project has them offline at the version installed.
The claim is machine-readable. composer.json carries extra.defence-before-fix, naming
the method and toolchain specification versions this package implements and listing every
known gap against the clause it fails, at two levels — the artefact a consumer installs, and
this repository as a project using it. The list is not yet empty, and the package does not
claim unqualified conformance while it is not; closing it is
Plan 00010. Check the claim
against the artefact rather than against this sentence:
# what this installed copy claims, and where it admits it falls short php -r 'echo json_encode(json_decode(file_get_contents("vendor/lts/php-qa-ci/composer.json"),true)["extra"]["defence-before-fix"], JSON_PRETTY_PRINT);' # every defence active in your project, without triggering any of them vendor/bin/rules . # what a failing identifier means and how to fix it correctly — offline vendor/bin/rule-doc phpqaci.nullCoalescingFalse
Working on a defect in a project that installs this package? Read CLAUDE/DefenceBeforeFix.md first — it is the single source of truth for how the method binds work here, including what an agent may decide and what only a human may.
Architecture
The pipeline is PHP. bin/qa is a PHP entrypoint that boots LTS\PHPQA\Pipeline\Cli\QaApplication;
the orchestration lives under src/Pipeline/ with one class per lane (src/Pipeline/Lane/*Tool.php),
a declarative tool registry that owns the -t aliases and phase order, one process runner, and one
run lock. Consumer configuration is PHP too: qaConfig/qa.php returns a closure adjusting a typed
QaConfigBuilder, qaConfig/tools/<name>.php replaces a lane, and qaConfig/hookPre.php /
hookPost.php are callables. The environment variables (CI, QA_READONLY, phpqaMemoryLimit,
useInfection, ...) keep their names. See Pipeline Architecture and, for a
project coming from the Bash-configured branches, Upgrading to 8.5.
Install
composer require --dev lts/php-qa-ci:dev-php8.5@dev
The qa script will be installed in your project's bin directory. By default, Composer uses vendor/bin, but you can configure a custom location in your composer.json:
"config": { "bin-dir": "bin" }
For Symfony projects, you can accept the prompts to run recipes, but you will then need to decide whether to stick with Symfony defaults or the php-qa-ci defaults (which are more extensive). If you decide to keep the php-qa-ci defaults, remove the config files created by the Symfony recipe:
# Revert to php-qa-ci PHPUnit configs (compare files first)
rm phpunit.xml.dist
ln -s vendor/lts/php-qa-ci/configDefaults/generic/phpunit.xml
Required Composer Configuration
Your project's composer.json must allow the required plugins:
{
"config": {
"allow-plugins": {
"lts/php-qa-ci": true,
"phpstan/extension-installer": true
}
}
}
Disabling Config Push
This project will push config updates direclty into the main repo
If this is not desired eg in production,staging,CI deployments then
export PHP_QA_CI_DISABLE_CONFIG_PUSH=true
What It Does
bin/qa runs every tool in a fixed order, in four phases, chosen to fail as early as
possible:
- Coding standards — the tools that modify code, so everything after them sees the final text (Rector, PHP CS Fixer, and Twig CS Fixer on a Twig project).
- Linting and validation — cheap, broad checks that need no type inference.
- Static analysis — PHPStan, architecture rules, and the always-on policy lanes.
- Testing — PHPUnit, then Infection. Copy/paste detection runs after a green run and reports only; it can never fail the pipeline.
The registry is the list, not this README. Phase membership, order, -t aliases and
gates all live in one place —
ToolRegistry — from which the CLI help text is
derived, and docs/pipeline.md is its prose companion.
For the lanes active in your project, with their phase and identifier, ask the tool:
vendor/bin/qa -h # every tool and its -t alias, derived from the registry vendor/bin/rules . # every defence active here, triggering none of them
Tool Delivery
PHP-QA-CI uses a hybrid approach to tool delivery:
- PHARs (via PHIVE): PHPStan, PHP CS Fixer, Infection, Composer Require Checker, PHPArkitect (PHIVE key
D9C905CED1932CA2— the trailing 16 chars of the full fingerprint47CD54B6398FE21B3709D0A4D9C905CED1932CA2, which is whatscripts/tool-install.bashpins), Twig CS Fixer, composer-normalize, parallel-lint (unsigned release asset, installed with--force-accept-unsigned) -- committed invendor-phar/ - Composer dependencies: PHPUnit, phpstan-strict-rules, phpstan-phpunit
- Self-built PHARs (via
scripts/build-phar.bashfrombuild/<tool>/manifests): Rector (vendor-phar/rector.phar, bundling its own extracted phpstan so nothing leaks into any consuming project's composer graph), phpcpd, composer-dependency-analyser, dead-code-detector (a PHPStan extension loaded into phpstan.phar by the opt-in deadCode lane) -- committed invendor-phar/ - In-process checks: PSR-4 validation, package type, config-template audit, infection config, version pins, PHPStan ignore justification, SensitiveParameter usage, markdown links and branch policy are PHP classes the pipeline calls directly; their
bin/<check>entrypoints remain for standalone use
Every PHAR is verified present at the start of each run (phive.xml is a hard requirement); nothing is fetched at run time.
The phpstan/phpstan package is in the replace section of composer.json since PHPStan is provided via PHAR. This prevents version conflicts when consuming projects also require PHPStan extensions.
PHPArkitect (architecture rules)
PHPArkitect enforces structural rules that PHPStan expresses awkwardly: class-naming conventions, namespace layering, and dependency direction. It runs in Phase 3 and is on by default.
Where does a rule belong — PHPArkitect or PHPStan?
Default to PHPArkitect for structural rules. Upgrade to a PHPStan rule only when you need finer-grained, method-level, or semantic detection that arkitect cannot express.
- PHPArkitect (the default) reasons about a class's identity: its kind (interface / enum / trait / class), its name, the namespace it sits in, and its ancestry. Reach for it for naming conventions, namespace layering, and dependency direction.
- PHPStan (the upgrade) reasons about code. Move up to a PHPStan rule only
when the check needs something arkitect cannot see or say:
- a method-level predicate — e.g. "the class has a public
__invoke"; - "any of N name patterns, except an allow-list" — arkitect's
HaveNameMatchingis a single glob with no OR / except composite; - a type-kind carve-out in a dependency rule — e.g. allow generated enums
but forbid generated objects;
NotDependsOnTheseNamespaceshas no type-kind awareness; - any behavioural / semantic check — type bans, call-site shape, docblock-driven rules, loose comparison, nested ternary.
- a method-level predicate — e.g. "the class has a public
One owner per convention, overlap by design only. When a structural convention
already lives in a PHPStan rule and arkitect can express it, prefer to migrate it
(and delete the PHPStan rule) so there is one place to change and one failure message.
(The shipped Interface / Enum / Trait suffix convention was migrated exactly this way:
it used to be the PHPStan RequireTypeSuffixRule and is now owned solely by the default
arkitect tier.) Overlap between engines is not itself a defect: two tools catching the
same class of problem is acceptable, sometimes unavoidable, as long as the two are kept
in sync and the overlap is documented where both live. What is a defect is silent
drift, where one engine's list is updated and the other's is not.
Rules are organised in tiers (mirroring the rules-default / rules-optional
PHPStan neon split). php-qa-ci ships each as a file returning a list of arkitect
ArchRule objects, and the pipeline exports the resolved path of each so a
project config can compose them without knowing the vendor layout:
| Tier | Env var | Default | Contents |
|---|---|---|---|
phparkitect-rules-default |
PHPQACI_ARKITECT_RULES_DEFAULT |
on, every project | Interface / Enum / Trait name suffixes, DTO convention |
phparkitect-rules-optional |
PHPQACI_ARKITECT_RULES_OPTIONAL |
opt-in | *Exception suffix, Abstract* prefix |
phparkitect-rules-optional-symfony |
PHPQACI_ARKITECT_RULES_OPTIONAL_SYMFONY |
opt-in | *Command, *Subscriber |
The default tier matches on AST node kind, on the class name and on the
namespace, so it never forces arkitect to resolve class ancestry — that keeps
it safe for any project. Ancestry-resolving rules (IsA/Extend/Implement,
e.g. the *Exception convention) need a complete autoloader, so they live in
the optional tier.
The DTO convention (default tier)
DTOs are suffixed, grouped and immutable. Four rules read one convention from three sides, so a DTO cannot be mistaken for a service and a service cannot hide in the DTO namespace:
- a class in a
Dtonamespace segment must be named*Dto; - a class named
*Dtomust live in aDtonamespace segment; - a class named
*Dtomust befinal— a DTO is a value carrier, not an extension point; - a class named
*Dtomust bereadonly— what a caller receives is what the producer sent.
The namespace match is on a segment named exactly Dto at any depth
(Acme\Billing\Dto\InvoiceDto), plus a root-level Dto namespace. A Dtos
segment does not match — rename it to Dto.
Interfaces, enums and traits are exempt from all four: they already carry their
own suffix under the rules above, so Dto\ShapeDtoInterface is correct and is
never asked to be *Dto, final or readonly. Abstract classes are not
exempt — an abstract *Dto fails the final rule, which is the intended
answer. If a project genuinely needs an inheritable or mutable DTO, drop its
own qaConfig/phparkitect-rules-default.php (the override lane described
below) rather than reaching for a suppression.
Troubleshooting: optional/symfony tiers need a complete autoloader
The optional and symfony tiers use ancestry rules (IsA) that resolve a class's
parents by reflecting it — so the analysed classes must be autoloadable. The
pipeline runs arkitect with --autoload=vendor/autoload.php, so this is normally
fine. But if you opt into these tiers and your autoloader is incomplete, IsA
rules silently match nothing — arkitect reports "No violations" (a false
green) rather than failing. (A genuine crash — exit > 1 — instead means a broken
config or an unparseable file.) If an opted-in *Exception/*Command/*Subscriber
rule never seems to fire, run composer dump-autoload and confirm your classes
load.
Project usage. With no project config, the default tier is applied to the
detected source dir automatically. To go further, add qaConfig/phparkitect.php
(copy templates/qaConfig-phparkitect.php) where you can:
- extend the default tier (
require getenv('PHPQACI_ARKITECT_RULES_DEFAULT')), - opt in to the optional / symfony tiers (their env vars),
- add project-bespoke rules,
- replace a tier wholesale by dropping your own
qaConfig/phparkitect-rules-*.php(resolved ahead of the shipped copy by the config-path lookup).
Disable arkitect for a project with ->withArkitect(false) in
qaConfig/qa.php (or useArkitect=0 in the environment for one run). Run it alone with vendor/bin/qa -t arch.
Excluding generated code (at any path)
Generated code (a jane-php OpenAPI client, protobuf stubs, an ORM proxy dir, …)
is regenerated from a spec and cannot be renamed to satisfy the naming rules,
so it must be excluded from analysis. The default config always excludes a
directory literally named Generated. For generated code that lives anywhere
else, declare the path(s) in qaConfig/qa.php:
// Each entry is excluded from arkitect IN ADDITION to the built-in 'Generated'. return static fn (QaConfigBuilder $qa): QaConfigBuilder => $qa ->withArkitectExcludedPaths('Quote/API', 'Generated/Client'); // excludes src/Quote/API/** etc.
- No config copy needed — the shipped default entry config honours these, so
you do not have to add
qaConfig/phparkitect.phpjust to exclude a path. (If you do use the override template, it honours them too — declare paths in this one place either way.) - Each entry is matched by arkitect (
Arkitect\Glob::toRegex) against the path relative tosrc/— a plain string is an unanchored substring match, and*/**globs are supported (*within a segment,**across separators). Use forward slashes on all platforms (Quote/API, neverQuote\API). - This narrows only the FILE SET; it never silences a rule. An entry that matches
nothing is a harmless no-op. Prefer it over
withArkitect(false), which drops the rules for the whole project rather than just the generated tree.
Custom PHPStan Rules
php-qa-ci ships a bundle of PHPStan rules: one set loaded automatically in every project, and two opt-in sets you include deliberately.
This README deliberately does not list them. A hand-maintained copy of the rule list drifts from the rules that actually load, and it drifts silently: nothing fails when the copy is wrong. A list of class names would be no better, because a class name is the one string PHPStan never prints, so it cannot be looked up by someone holding a failure. Each question below has exactly one place that answers it:
| Question | Where it is answered |
|---|---|
| What does this identifier mean and how do I fix it? | vendor/bin/rule-doc <identifier>, or docs/phpstan-rules/README.md |
| Which defences are active in my project, right now? | vendor/bin/rules . — reads your resolved config, triggers nothing |
| Which rules load automatically? | rules-default.neon — the wiring is the list |
| What is opt-in, and how do I enable it? | docs/tools/phpstan.md — the bundles and the includes: to add |
| How do I configure a rule that takes parameters? | That rule's page, reached from its identifier |
| How do I write my own? | CLAUDE/DefenceBeforeFix.md, then vendor/bin/phpstan-rule to prove it fires |
Two things are worth saying here rather than by reference, because they are easy to get backwards:
- The always-on set is only always-on for projects that include this library's rules neon. PHPStan rules cannot be relied on estate-wide, which is why checks that must hold everywhere ship as pipeline lanes instead — see SensitiveParameter usage check for the worked example of that distinction.
- One rule ships in neither bundle.
ForbidMagicStringAssertionRuleis experimental and high-noise, and must be cherry-picked deliberately.
SensitiveParameter usage check (always-on)
Unlike the PHPStan rules above (which are opt-in), php-qa-ci ships an always-on
pipeline tool that asserts the native #[\SensitiveParameter] attribute is used at
least once in your project's src/. PHP 8.2+ redacts a so-marked argument from
stack traces, keeping passwords / tokens / secrets out of logs and error reporters.
The check runs automatically as part of bin/qa for every consumer — no neon
include required. The scan is AST-based, so the attribute is never false-matched in
strings or comments.
- Run standalone:
vendor/bin/qa -t sensitiveParameterUsage(aliases:spu,sensitiveparameter). - Passes when ≥1
#[\SensitiveParameter]is found; fails (exit 1) when none is found.
Escape hatch (opt-out, on by default) — for projects that genuinely never handle
a sensitive parameter (e.g. pure tooling libraries). Add to qaConfig/qa.php:
return static fn (QaConfigBuilder $qa): QaConfigBuilder => $qa ->withSensitiveParameterCheck(false);
php-qa-ci itself is the canonical example: it handles no secrets, so it sets this in its own qaConfig/qa.php.
Estate-wide impact: because this is always on, every consumer's
bin/qanow requires either at least one#[\SensitiveParameter]annotation or the opt-out flag above. Most projects should add the annotation rather than opt out.
Full details: docs/tools/sensitiveParameterUsage.md.
Quick Setup Scripts
GitHub Actions Setup
Automatically install the GitHub Actions workflow for continuous integration:
vendor/lts/php-qa-ci/scripts/install-github-actions.bash
This will:
- Create
.github/workflows/qa.ymlwith an optimized QA pipeline - Auto-detect your PHP version from
composer.json - Configure smart caching for faster builds
- Set up artifact storage for test results
Branch Protection Setup
Configure GitHub branch protection rules with sensible defaults:
# Standard protection (admins can bypass) vendor/lts/php-qa-ci/scripts/setup-branch-protection.bash # Hardened protection (CI enforced for everyone) vendor/lts/php-qa-ci/scripts/setup-branch-protection.bash --harden
Prerequisites: Requires GitHub CLI (gh) installed and authenticated.
CI/CD Workflows
PHP-QA-CI includes three GitHub Actions workflows in .github/workflows/:
ci.yml-- Runs on push/PR tophp8.5, executesbash ci.bashqa.yml-- Template workflow for consuming projects (copy to your project)update-deps.yml-- Weekly scheduled workflow that updates all dependencies (Composer, PHARs via PHIVE, the Rector PHAR), runs the full QA pipeline, and creates an auto-merge PR if green
Two consuming-project templates live in templates/github-actions/:
php-qa-ci.yml-- single-job pipeline; optionalAUTO_COMMIT_FIXEScommits fixes at the end (so the checks ran against unfixed code)qa-autofix.yml-- inline-barrier: a PR-onlyautofixjob applies Rector + PHP CS Fixer in write mode and commits the fixes back, then agatejob (needs: autofix) re-validates the fixed tip read-only in the same run (no PAT, no re-trigger needed)
See GitHub Actions Integration for setup details.
Claude Code Integration
PHP-QA-CI integrates with Claude Code to provide development guardrails and automation.
Deployment
Deploy skills and hooks to your project:
vendor/lts/php-qa-ci/scripts/deploy-skills.bash vendor/lts/php-qa-ci .
This will:
- Copy hooks to
.claude/hooks/ - Register them in
.claude/settings.json - Detect and configure hooks-daemon if present (see hooks-daemon documentation for installation)
- Migrate from legacy classic hooks if found
Included Hooks
php-qa-ci__auto-continue.py-- Reduces confirmation promptsphp-qa-ci__prevent-destructive-git.py-- Blocks commands that destroy uncommitted changesphp-qa-ci__discourage-git-stash.py-- Discourages git stash with escape hatchphp-qa-ci__block-plan-time-estimates.py-- Prevents time estimates in plan documentsphp-qa-ci__validate-claude-readme-content.py-- Ensures docs contain instructions, not logsphp-qa-ci__enforce-markdown-organization.py-- Enforces doc organization
See .claude/hooks/README.md for detailed hook documentation after deployment.
Disabling Auto-Deployment (Dev / Staging / CI Hosts)
Skills, agents and hooks are deployed automatically on every composer install
and composer update via the SkillsDeployPlugin. This is intentional --
keeping .claude/ config consistent across projects is a core goal.
On hosts where this is unwanted (dev / staging deploys, build images, CI runners that aren't Claude Code environments) the deployment can leave the working tree dirty. Opt out by exporting:
export PHP_QA_CI_DISABLE_CONFIG_PUSH=true
When set (any truthy value -- true, 1, yes, on), the plugin logs that it
was disabled and exits without touching .claude/. When unset (the default), the
plugin logs the opt-out instructions every time it runs so deploy operators can
discover the flag.
Composer Plugins
PHP-QA-CI registers three Composer plugins (composer.json extra.class):
- SkillsDeployPlugin -- Deploys Claude Code skills and hooks
- PhpStanGuardPlugin -- Prevents
phpstan/phpstanfrom being installed alongside the PHAR - ManagedSourceDeployPlugin -- Regenerates the managed
<RootNs>\PhpQaCi\source tree on install/update (gated by the samePHP_QA_CI_DISABLE_CONFIG_PUSHflag asSkillsDeployPlugin); see CLAUDE/managed-source.md
Docs
Comprehensive documentation is available in the ./docs folder:
- Pipeline Architecture -- Tool execution order and phases
- Tools Overview -- All tools with configuration details
- Configuration -- Customizing tool settings and overrides
- Extending the Pipeline -- Adding your own tools and phases from
qaConfig/pipeline.php - Upgrading to 8.5 -- Migrating a project's
qaConfig/from the Bash-configured branches - Coding Standards -- PHP CS Fixer and Rector configuration
- GitHub Actions Integration -- CI/CD setup guide
- Continuous Integration -- General CI usage and workflows
- Platform Detection -- Symfony-specific settings
Tool-specific documentation:
- PHPStan -- Static analysis configuration and custom rules
- PHPUnit -- Test runner configuration and modes
- Infection -- Mutation testing setup
- Package Type -- The always-on
composer.jsontypecheck - Version Pins -- The always-on check that phpunit.xml, safe scan-files and GitHub Actions PHP pins match the toolchain in use
- API-surface classification (
@api/@internal) -- the rule that requires every public class-like in a library to be one or the other - SensitiveParameter Usage -- The always-on
#[\SensitiveParameter]check
Other Notes
Specify PHP Binary Path
If you are running multiple PHP versions, you can specify which one to use:
export PHP_QA_CI_PHP_EXECUTABLE=/bin/php85 vendor/bin/qa # Or inline: PHP_QA_CI_PHP_EXECUTABLE=/bin/php85 vendor/bin/qa
Running Specific Tools
# Run only PHPStan vendor/bin/qa -t stan # Run only PHP CS Fixer vendor/bin/qa -t fixer # Run on specific path vendor/bin/qa -t stan -p src/Domain
Branches
php8.5-- Targets PHP 8.5php8.4-- Default branch, targets PHP 8.4php8.3-- Targets PHP 8.3
Long Term Support
This package was brought to you by Long Term Support LTD, a company run and founded by Joseph Edmonds.
You can get in touch with Joseph at https://ltscommerce.dev/
Check out Joseph's recent book The Art of Modern PHP 8