Search by

lts / php-qa-ci

lts

Simple PHP QA pipeline and scripts. Largely just a collection of dependencies with configuration and scripts to run them together

Package info

github.com/LongTermSupport/php-qa-ci

Type:composer-plugin

pkg:composer/lts/php-qa-ci

Statistics

Installs: 7 272

Dependents: 6

Suggesters: 0

Stars: 0

Open Issues: 4

dev-php8.5 2026-09-12 08:58 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:

  1. 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).
  2. Linting and validation — cheap, broad checks that need no type inference.
  3. Static analysis — PHPStan, architecture rules, and the always-on policy lanes.
  4. 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 fingerprint 47CD54B6398FE21B3709D0A4D9C905CED1932CA2, which is what scripts/tool-install.bash pins), Twig CS Fixer, composer-normalize, parallel-lint (unsigned release asset, installed with --force-accept-unsigned) -- committed in vendor-phar/
  • Composer dependencies: PHPUnit, phpstan-strict-rules, phpstan-phpunit
  • Self-built PHARs (via scripts/build-phar.bash from build/<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 in vendor-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 HaveNameMatching is 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; NotDependsOnTheseNamespaces has no type-kind awareness;
    • any behavioural / semantic check — type bans, call-site shape, docblock-driven rules, loose comparison, nested ternary.

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 Dto namespace segment must be named *Dto;
  • a class named *Dto must live in a Dto namespace segment;
  • a class named *Dto must be final — a DTO is a value carrier, not an extension point;
  • a class named *Dto must be readonly — 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.php just 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 to src/ — 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, never Quote\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. ForbidMagicStringAssertionRule is 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/qa now 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.yml with 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 to php8.5, executes bash ci.bash
  • qa.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; optional AUTO_COMMIT_FIXES commits fixes at the end (so the checks ran against unfixed code)
  • qa-autofix.yml -- inline-barrier: a PR-only autofix job applies Rector + PHP CS Fixer in write mode and commits the fixes back, then a gate job (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 prompts
  • php-qa-ci__prevent-destructive-git.py -- Blocks commands that destroy uncommitted changes
  • php-qa-ci__discourage-git-stash.py -- Discourages git stash with escape hatch
  • php-qa-ci__block-plan-time-estimates.py -- Prevents time estimates in plan documents
  • php-qa-ci__validate-claude-readme-content.py -- Ensures docs contain instructions, not logs
  • php-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/phpstan from being installed alongside the PHAR
  • ManagedSourceDeployPlugin -- Regenerates the managed <RootNs>\PhpQaCi\ source tree on install/update (gated by the same PHP_QA_CI_DISABLE_CONFIG_PUSH flag as SkillsDeployPlugin); see CLAUDE/managed-source.md

Docs

Comprehensive documentation is available in the ./docs folder:

Tool-specific documentation:

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.5
  • php8.4 -- Default branch, targets PHP 8.4
  • php8.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