ctw / ctw-qa
Configuration for commonly used quality assurance (QA) tools for PHP projects.
Requires
- php: ^8.4
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: ^2.6.0
- shipmonk/composer-dependency-analyser: ^1.8.4
- symplify/easy-coding-standard: ^13.0
Requires (Dev)
- phpunit/phpunit: ^13.3
- symfony/var-dumper: ^8.0
This package is auto-updated.
Last update: 2026-08-31 18:26:56 UTC
README
Centralized, opinionated configuration for PHP 8.3+ quality assurance tools: Rector, Easy Coding Standard (ECS), PHPStan and Composer Dependency Analyser.
Introduction
Why This Library Exists
Setting up quality assurance tools properly is tedious and error-prone. Each project requires configuring:
- Rector for code modernization (100+ lines of configuration)
- Easy Coding Standard for style enforcement (150+ lines)
- PHPStan for static analysis (50+ lines)
- Composer Dependency Analyser for shadow, unused and misplaced dependencies (30+ lines)
Multiplied across numerous projects, this becomes a maintenance burden. Configurations drift, standards diverge, and teams waste time debugging tool setups instead of writing code.
This library provides:
- Battle-tested defaults: Years of PHP best practices encoded in reusable configuration classes
- PHP 8.3+ standards: Modern PHP syntax, strict types, and property promotion
- PSR-12 compliance: Full adherence to PHP coding standards
- Extensible architecture: Override any default via class inheritance
- Consistent tooling: Identical QA configuration across all projects
Problems This Library Solves
- Configuration drift: Projects diverge in coding standards over time
- Repetitive setup: Same boilerplate written for every new project
- Inconsistent quality: Different strictness levels across codebases
- Maintenance burden: Tool updates require changes in multiple places
- Onboarding friction: New developers must learn project-specific configurations
Where to Use This Library
- New PHP projects: Start with modern, strict standards from day one
- Existing codebases: Gradually modernize legacy code with Rector
- Monorepos: Share identical QA configuration across all packages
- CI/CD pipelines: Automated quality gates with consistent rules
- Open source libraries: Enforce professional-grade code quality
Design Goals
- Opinionated defaults: Strong opinions for modern PHP, easily overridable
- Invokable classes: Simple
$config()syntax for integration - Maximum strictness: PHPStan level
max, strict comparisons, strict types - Minimal dependencies: Only the four QA tools themselves
- Extensible: All configuration classes designed for inheritance
Requirements
- PHP 8.3 or higher
- Composer
Installation
Install by adding the package as a Composer requirement:
composer require ctw/ctw-qa --dev
Usage Examples
Rector Configuration
Create rector.php in your project root:
<?php declare(strict_types=1); use Ctw\Qa\Rector\Config\RectorConfig\DefaultFileExtensions; use Ctw\Qa\Rector\Config\RectorConfig\DefaultSets; use Ctw\Qa\Rector\Config\RectorConfig\DefaultSkip; use Rector\Config\RectorConfig; return static function (RectorConfig $rectorConfig): void { $fileExtensions = new DefaultFileExtensions(); $sets = new DefaultSets(); $skip = new DefaultSkip(); $rectorConfig->fileExtensions($fileExtensions()); $rectorConfig->sets($sets()); $rectorConfig->paths(['src', 'test']); $rectorConfig->skip([...$skip()]); };
ECS Configuration
Create ecs.php in your project root:
<?php declare(strict_types=1); use Ctw\Qa\EasyCodingStandard\Config\ECSConfig\DefaultFileExtensions; use Ctw\Qa\EasyCodingStandard\Config\ECSConfig\DefaultIndentation; use Ctw\Qa\EasyCodingStandard\Config\ECSConfig\DefaultLineEnding; use Ctw\Qa\EasyCodingStandard\Config\ECSConfig\DefaultRules; use Ctw\Qa\EasyCodingStandard\Config\ECSConfig\DefaultRulesWithConfiguration; use Ctw\Qa\EasyCodingStandard\Config\ECSConfig\DefaultSets; use Ctw\Qa\EasyCodingStandard\Config\ECSConfig\DefaultSkip; use Symplify\EasyCodingStandard\Config\ECSConfig; use Symplify\EasyCodingStandard\Configuration\ECSConfigBuilder; // Wrapped in an immediately-invoked closure: ECS require()s this file in the // scope of its container factory, where the container is held in a variable // named $ecsConfig. Building at file scope would clobber it; the closure keeps // every local contained. return (static function (): ECSConfigBuilder { $fileExtensions = new DefaultFileExtensions(); $indentation = new DefaultIndentation(); $lineEnding = new DefaultLineEnding(); $rules = new DefaultRules(); $rulesConfig = new DefaultRulesWithConfiguration(); $sets = new DefaultSets(); $skip = new DefaultSkip(); $ecsConfig = ECSConfig::configure() ->withFileExtensions($fileExtensions()) ->withSpacing(indentation: $indentation(), lineEnding: $lineEnding()) ->withPaths(['src', 'test']) ->withSets($sets()) ->withRules($rules()) ->withSkip($skip()); foreach ($rulesConfig() as $checkerClass => $configuration) { $ecsConfig->withConfiguredRule($checkerClass, $configuration); } return $ecsConfig; })();
PHPStan Configuration
Create phpstan.neon in your project root:
parameters: level: max paths: - src - test bootstrapFiles: - vendor/autoload.php
Composer Dependency Analyser Configuration
Create composer-dependency-analyser.php in your project root:
<?php declare(strict_types=1); use Ctw\Qa\ComposerDependencyAnalyser\Config\Configuration\DefaultFileExtensions; use Ctw\Qa\ComposerDependencyAnalyser\Config\Configuration\DefaultIgnoredPackageErrors; use Ctw\Qa\ComposerDependencyAnalyser\Config\Configuration\DefaultIgnoredUnknownClassPatterns; use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; $fileExtensions = new DefaultFileExtensions(); $packageErrors = new DefaultIgnoredPackageErrors(); $unknownClassPatterns = new DefaultIgnoredUnknownClassPatterns(); $configuration = new Configuration(); $configuration->setFileExtensions($fileExtensions()); foreach ($packageErrors() as $packageName => $errorTypes) { $configuration->ignoreErrorsOnPackage($packageName, $errorTypes); } foreach ($unknownClassPatterns() as $unknownClassPattern) { $configuration->ignoreUnknownClassesRegex($unknownClassPattern); } // The analyser scans the autoload paths of composer.json on its own, which // covers "src" and "test" but not the root of the project. Add whichever // root-level files reference a dependency, so that scanning them proves it. $configuration->addPathsToScan( [ sprintf('%s/composer-dependency-analyser.php', __DIR__), sprintf('%s/ecs.php', __DIR__), sprintf('%s/rector.php', __DIR__), ], false ); return $configuration;
The file returns a Configuration object rather than a closure, and needs no
require of vendor/autoload.php to reach the Ctw\Qa classes: the analyser
loads the vendor directory belonging to the composer.json it is analysing
before it reads the configuration.
Composer Dependency Analyser answers four questions rather than one. Beyond the
declared dependency nothing uses, it reports the shadow dependency — a class
used from a package your composer.json never declared, which arrives only
because something else happens to require it — and the dependency declared in
the wrong section of composer.json, either way round. It resolves a symbol
back to the package that autoloads it, which leaves two ways to handle a
dependency no scan can see: prove it where a file can, exclude the error where
none can.
Prove it, by scanning the file that references it. The analyser scans the
autoload paths from your composer.json, which leaves the project root out.
addPathsToScan() puts the root-level configuration files back in, which is
what proves the tools they configure. Pass false for $isDev when the tools
are production requirements, true when they are development ones — the
analyser reports a mismatch either way round.
Exclude the error, where the package is invisible to a symbol scan. The
PHPStan packages are wired up through phpstan.neon and named in no PHP file,
so ignoreErrorsOnPackage() excludes UNUSED_DEPENDENCY for each of them.
There is no pattern equivalent, so a PHPStan extension added to a project is
named individually.
Exclude the unknown class, where it cannot be autoloaded. ECS names its
fixers and sniffs with the classes of friendsofphp/php-cs-fixer and
squizlabs/php_codesniffer but requires neither, bundling both in a nested
vendor tree behind an autoloader that is only registered once the tool boots. A
configuration class naming a fixer therefore references a class that resolves at
runtime and is unknown to the analyser, and ignoreUnknownClassesRegex()
excludes the two namespaces wholesale.
One pattern covers both, and it has to. ECS's bootstrap.php is autoloaded
eagerly but registers a lazy autoloader that acts only on a class named
Symplify\… or ECSPrefix…; the first such class requested makes it load the
nested vendor/autoload.php, and from that point PhpCsFixer\… resolves and
is attributed to symplify/easy-coding-standard instead of being unknown.
PHP_CodeSniffer\… stays unknown either way, being prefixed in that tree. So
whether the fixers are unknown depends on whether a Symplify\ symbol was
scanned before them, which differs between one filesystem and the next. Split
across two patterns, the fixer one goes unmatched wherever the sniffs are
scanned last — and since an unmatched ignore is itself an error, the check
would pass locally and fail in CI. Joined, the sniffs alone match it.
Exclusions that never fire are reported in their own right, so treat the defaults as a starting point: a project with no PHPStan extension installed is told that the entry for it matched nothing.
Composer Scripts
Add to your composer.json:
{
"scripts": {
"qa": ["@rector", "@ecs", "@phpstan", "@composer-dependency-analyser"],
"qa-fix": ["@rector-fix", "@ecs-fix", "@phpstan", "@composer-dependency-analyser"],
"rector": "vendor/bin/rector process --dry-run",
"rector-fix": "vendor/bin/rector process",
"ecs": "vendor/bin/ecs",
"ecs-fix": "vendor/bin/ecs --fix",
"phpstan": "vendor/bin/phpstan analyse",
"composer-dependency-analyser": "vendor/bin/composer-dependency-analyser"
}
}
Run QA checks:
composer qa # Check everything (dry-run) composer qa-fix # Auto-fix everything possible
Included Rule Sets
Rector (Code Modernization)
| Set | Description |
|---|---|
UP_TO_PHP_83 |
Modernizes code to PHP 8.3 syntax |
PHPUNIT_100 |
Upgrades PHPUnit to version 10.0+ |
CODE_QUALITY |
Simplifies expressions, removes redundancy |
CODING_STYLE |
Enforces consistent style |
DEAD_CODE |
Removes unused code |
NAMING |
Improves naming conventions |
ECS (Code Style)
| Rule | Description |
|---|---|
DeclareStrictTypesFixer |
Adds declare(strict_types=1) |
DisallowLongArraySyntaxSniff |
Enforces short array syntax [] |
StrictComparisonFixer |
Enforces === over == |
NoUnusedImportsFixer |
Removes unused imports |
OrderedImportsFixer |
Alphabetizes imports |
TrailingCommaInMultilineFixer |
Adds trailing commas |
PHPStan (Static Analysis)
- Level:
max(strictest) - Strict rules enabled
- PHPUnit extension included
Composer Dependency Analyser (Dependency Hygiene)
| Exclusion | Description |
|---|---|
phpstan/extension-installer |
Composer plugin, referenced from no PHP file |
phpstan/phpstan |
Prefixed PHAR, configured through phpstan.neon rather than from code |
phpstan/phpstan-phpunit |
Wired through phpstan.neon rather than referenced in code |
phpstan/phpstan-strict-rules |
Wired through phpstan.neon rather than referenced in code |
/^(PHP_CodeSniffer|PhpCsFixer)\\/ |
Sniffs and fixers bundled inside the ECS vendor tree, unknown to the analyser |
Only UNUSED_DEPENDENCY is excluded per package, so every other kind of error
is still reported for them. rector/rector,
shipmonk/composer-dependency-analyser and symplify/easy-coding-standard are
deliberately absent: a scanned file references each one, which proves it used.
Customization
Extend any configuration class to modify defaults:
<?php declare(strict_types=1); namespace App\QA; use Ctw\Qa\Rector\Config\RectorConfig\DefaultSkip; class CustomSkip extends DefaultSkip { public function __invoke(): array { $skip = parent::__invoke(); $skip[] = '*/legacy/*'; return $skip; } }
Use your custom class:
$skip = new \App\QA\CustomSkip(); $rectorConfig->skip([...$skip()]);