This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
vitek-dev/qa is not an application — it is the build definition for a reusable PHP quality-assurance Docker image, published to ghcr.io/vitek-dev/qa. There is no PHP source to run here; the repo produces an image that consumers mount their own project into (at /app) and run QA tools against.
The bundled tools (installed globally via Composer in the Dockerfile) are exposed on PATH as:
phpstan/stan— PHPStan static analysisphpcs/cs— PHP_CodeSnifferphpcbf/cbf— PHP Code Beautifier (auto-fix)deptrac— Deptrac architectural dependency checksinit— scaffolds default config files into the mounted project (seescripts/init.sh)
Two coding standards are pre-registered with PHP_CodeSniffer's installed_paths: the VitekDevCodingStandard ruleset, which lives in this repo at config/phpcs/standards/VitekDevCodingStandard/ruleset.xml and is copied into the image at /config/phpcs/standards, and slevomat/coding-standard (a Composer dependency the ruleset's sniffs are built on).
VitekDevCodingStandard is structured as a PSR12 base plus a curated set of Slevomat sniffs that add semantic strictness (strict types, modern syntax, dead-code/unused checks, import policy) on top of PSR-12's formatting. Slevomat sniffs that PSR-12 already covers or conflicts with have been deliberately removed; each removal is left as an inline <!-- Removed ... --> comment naming the PSR-12 sniff that replaces it. When editing the ruleset, do not re-introduce formatting sniffs PSR-12 owns, and keep those removal notes intact.
Build the image locally (single platform). The FROM php:%%PHP_VERSION%%-alpine placeholder must be substituted with a concrete PHP version first (a bare docker build fails until it is):
PHP_VERSION=8.4
sed "s/%%PHP_VERSION%%/${PHP_VERSION}/" Dockerfile \
| docker build -t vitek-dev/qa:v${PHP_VERSION} -f - .Run the tools against a project by mounting it at /app:
docker run --rm -v "$PWD":/app vitek-dev/qa:v8.4 phpcs # lint
docker run --rm -v "$PWD":/app vitek-dev/qa:v8.4 phpcbf # auto-fix style
docker run --rm -v "$PWD":/app vitek-dev/qa:v8.4 phpstan analyse
docker run --rm -v "$PWD":/app vitek-dev/qa:v8.4 init # generate default configs
docker run --rm -it -v "$PWD":/app vitek-dev/qa:v8.4 # interactive shell (sh; default CMD)Multi-platform build (matches CI — requires buildx/QEMU; same %%PHP_VERSION%% substitution applies):
PHP_VERSION=8.4
sed "s/%%PHP_VERSION%%/${PHP_VERSION}/" Dockerfile \
| docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/vitek-dev/qa:v${PHP_VERSION} -f - .Publish the current main to every php/* release branch at once (each push triggers one CI image build — prompts before pushing):
./bin/publish-php-branches.sh # -n dry run, -y skip prompt, -f overwrite diverged branchesDockerfileis the heart of the project. It isFROM php:%%PHP_VERSION%%-alpine, where%%PHP_VERSION%%is a placeholder for the PHP version that must be substituted at build time (a baredocker buildfails until it is) — the version comes from the branch (see release pipeline below). It installs thezipextension (compiled via a throwaway--virtual .build-depsapk group that isapk del'd in the same layer, so the toolchain stays out of the final image) and Composer, setsminimum-stability dev, thencomposer global requires the QA tools and symlinks their binaries from$HOME/.composer/vendor/bininto/usr/local/bin(including the short aliasescs/cbf/stan). Thephpcs --config-set installed_paths ...step is what makes the custom and slevomat standards discoverable.--ignore-platform-reqsis required on the global require (see commit history) because tools declare constraints the base image doesn't satisfy. The Alpine base keeps the image ~195 MB (vs ~690 MB on the previous Debian/bookworm base); the default shell issh, not bash.scripts/is copied to/scriptsin the image and made executable.scripts/init.shis symlinked as/usr/local/bin/init. It is idempotent: it refuses to run if/appis empty, then for each default config (phpstan.neon,phpstan-baseline.neon,phpcs.xml,deptrac.yaml)cps the template from/config/<tool>/into/apponly when that file doesn't already exist. The script itself holds no config content — it's a thin loop overinstall_config <source-path> <destination-filename>; the actual defaults live as real files underconfig/(see below).config/is organised per tool:config/phpstan/phpstan.neon(level 5, pathssrc,includesthe baseline) plus an emptyconfig/phpstan/phpstan-baseline.neon(consumers regenerate it withphpstan analyse --generate-baseline),config/phpcs/phpcs.xml(referencesVitekDevCodingStandard, scanssrc, excludes*/vendor/*),config/deptrac/deptrac.yaml(DDD layering —Domain/Application/Infrastructurematched bysrc/<Context>/<Layer>/.*directory globs), andconfig/phpcs/standards/VitekDevCodingStandard/ruleset.xml(the phpcs standard itself, registered viainstalled_paths). To add or change a consumer default, edit/add the file underconfig/<tool>/and reference it from aninstall_configcall ininit.sh..github/workflows/build.yamlis the release pipeline. It triggers on pushes tophp/*branches, derives the version by stripping thephp/prefix from the branch name (${GITHUB_REF_NAME#php/}), then ased -i "s/%%PHP_VERSION%%/<version>/" Dockerfilestep substitutes the placeholder so the base image matches, and finally builds/pushes the multi-platform image toghcr.io/${{ github.repository }}:v<version>(note thevprefix). So pushing branchphp/8.4substitutesFROM php:8.4-alpineand publishesghcr.io/vitek-dev/qa:v8.4— tag and base PHP version stay in lock-step automatically. There is no test suite or staging tag, and no:latestis published — the branch is the release. Action versions are pinned to floating major tags.bin/holds maintainer-only helper scripts that are deliberately not part of the image — onlyscripts/andconfig/areCOPY'd in, so anything that should stay out of the published image goes here, not inscripts/.bin/publish-php-branches.shis the release helper: because everyphp/*branch is byte-identical tomain(the branch name alone selects the PHP version), it fast-forwards eachphp/*branch on the remote up tomainand pushes them, firing one CI build per branch — i.e. it rolls a singlemaincommit out to all published PHP versions at once. It is plain bash targeting macOS's bash 3.2 (nomapfile), prompts before pushing, skips branches that have diverged frommainunless given-f, and supports-n(dry run) /-y(no prompt).
- The PHP version flows from the branch name: branch
php/X.Y→ image tag:X.Y(workflow) and base imagephp:X.Y-alpine(the%%PHP_VERSION%%placeholder in theDockerfile'sFROM). The placeholder must be substituted with the sameX.Yvalue the tag uses, so the:X.Yimage actually ships PHP X.Y. Keep tag and base version in lock-step. - The
scripts/init.shshebang is#!/bin/shand the image'sCMDissh(Alpine has no bash by default). Keepinit.shPOSIX-clean — no bashisms — or addbashback to theapk addline. This POSIX constraint applies toscripts/(which runs inside the image); maintainer scripts inbin/run on the developer's host instead, so they may use bash but should stay compatible with macOS's bash 3.2. - Adding or upgrading a QA tool means editing the
composer global requirelist in theDockerfile, and adding a/usr/local/binsymlink for its binary if it should be callable directly. - Adding a coding standard requires appending its path to the
phpcs --config-set installed_pathslist. For Composer-installed standards that's a vendor path; the in-repoVitekDevCodingStandardis shipped underconfig/phpcs/standards/(copied to/config/phpcs/standards) instead — editconfig/phpcs/standards/VitekDevCodingStandard/ruleset.xmlto change the rules. - The default config templates are real files under per-tool folders
config/phpstan/,config/phpcs/,config/deptrac/(copied to/config/<tool>/, scaffolded byinit). Keepphpstan.neon/phpcs.xml/deptrac.yamlin sync with the standards the image ships — e.g.phpcs.xmlmust keep referencingVitekDevCodingStandard. Adding a new default = drop a file underconfig/<tool>/and add aninstall_config <source-path> <destination-filename>line toscripts/init.sh.