Skip to the content.

← Back to README · Documentation index

QA tools

9. QA Tools Initialization (qa-init)

Configures and installs the necessary QA tools (PHPStan, coding standards, ESLint, Stylelint) in your bundle.

orobox qa-init

ESLint and Stylelint are the exception to “installs”: Orobox runs OroCommerce’s own binaries from /var/www/oro/node_modules/.bin, in both type: bundle and type: project. The configuration those two linters are handed is OroCommerce’s — .eslintrc.yml and .stylelintrc.yml at the application root, extending packages its package.json declares — so the only version guaranteed to satisfy it is the one the application installed for npm run eslint-oro. qa-init therefore installs only what the application does not provide itself: the two GitLab Code Quality formatters, plus the ESLint plugins and shareable config OroCommerce’s own .eslintrc.yml names but its generated package.json does not install (eslint-plugin-no-jquery is one). Those fillers are symlinked into /var/www/oro/node_modules at run time, and only where the application has nothing of its own there — nothing is ever written to its package.json.

The linters appear with the application’s node_modules, which the asset install populates. In CI the QA step installs them itself when they are not there — a cached or database-seeded Oro install never runs the asset install — so nothing extra is needed there. On a developer’s stack, orobox qa stops with the reason when they are missing: build the assets once (php bin/console oro:assets:install).

qa-init also writes a configuration stub for each enabled tool into your source root, when the file is not there yet. The stubs are yours from the first write on: Orobox layers them on top of the shared standard rather than replacing it — see Project configuration files — so what you put in them wins without repeating the standard.

Stub Written for
phpstan.neon, rector.php, .php-cs-fixer.dist.php, .twig-cs-fixer.php every install type
.eslintrc.yml, .stylelintrc.yml, .stylelintrc-css.yml type: bundle only

The JS stubs are bundle-only because a project install roots its sources at the same directory as OroCommerce’s own copies of those files, so a stub there would replace them rather than extend them. The ignore files (.eslintignore, .stylelintignore) are never generated for the same reason: they are chosen, not merged.

The finder in .php-cs-fixer.dist.php and .twig-cs-fixer.php is the tree those tools actually walk — the merge takes rules from both sides but the finder from yours. Edit it rather than deleting it.

Last, qa-init offers to install a git pre-commit hook. See The pre-commit hook.

10. Run QA Tools (qa)

Executes the QA analysis tools. When no flag is provided, runs the tools enabled in .orobox.yaml under test.qa (all tools are enabled by default if not configured).

orobox qa

Options:

CLI flags always override the configuration. Example:

orobox qa --phpstan --eslint

Agent mode (--agent)

--agent is the global flag described in Agent mode; on orobox qa it changes what the run prints rather than what it does. The tools still run exactly as they would without it — locally they still fix what they can — but instead of each tool’s own output, the command prints one line per finding:

fixed 7 files (php-cs-fixer 5, rector 2)
src/Entity/Order.php:42 error phpstan Access to undefined property App\Entity\Order::$foo
src/Form/Type/OrderType.php:11 major eslint semi Missing semicolon.

The grammar is path:line severity tool [check] message. The line number is omitted when the tool reported none. Findings are sorted by path and then by line, so two runs over an unchanged tree produce identical output. Severity is the tool’s own value — blocker, critical, major, minor, info — not remapped.

The first line appears only when a fixer rewrote something. PHP-CS-Fixer and Rector report the files they changed rather than problems left behind, so they are counted rather than listed: the caller’s next move is to re-read the tree, whatever the file names were.

At most 50 findings are printed. When there are more, a final line says how many were dropped:

... 38 more (--report=gitlab for the full list)

A tool that exits non-zero without writing any findings did not fail the tree, it failed to run — a missing binary, a broken configuration, a PHP fatal. That is reported on stderr and fails the run:

error: phpstan could not run (exit 255)

Exit codes:

A clean tree prints nothing at all and exits 0.

The pre-commit hook

At the end of its run, orobox qa-init offers to install a git pre-commit hook. The hook checks each commit before it is written:

orobox qa --staged      # the enabled QA tools, over the staged files
orobox test             # only when the commit stages at least one .php file

Either one failing stops the commit. Nothing is rewritten: --staged puts every tool in check-only mode, because the hook runs between the index being built and the commit being written, and a fix applied there would change contents you already reviewed and staged — in the working tree, while the commit records the index.

The staged set is the subset you ticked. PhpStorm stages the checked files, and for a partial commit the checked hunks, before it runs the hook, so the tools see exactly what the commit contains. The same holds for git add -p.

Two things are never narrowed:

  why
PHPStan a changed class breaks the type checks of callers this commit never touched
The test suite a changed entity or service breaks tests in files this commit never touched

Narrowing either one would report a green run that is not green. The test suite is skipped altogether when the commit stages no PHP, which is what keeps a commit of templates or assets from paying for it.

Everything else is narrowed by extension, and a tool with nothing staged for it does not run at all:

Tool Files
Rector, PHP-CS-Fixer .php
Twig-CS-Fixer .twig
ESLint .js
Stylelint .scss, .less, .sass, .html
Stylelint (CSS) .css

PHP-CS-Fixer is given --path-mode=intersection, so the finder in your .php-cs-fixer.dist.php still decides what is in scope: a staged file you deliberately excluded stays excluded.

The same hook serves people and LLM agents: it adds --agent (minimal output) to qa --staged and test when CLAUDECODE, CURSOR_AGENT, GEMINI_CLI or OROBOX_AGENT is set in the committer’s environment, and runs them plain otherwise. Set OROBOX_AGENT=1 for an agent that sets none of those.

To skip the hook for one commit:

OROBOX_SKIP_PRECOMMIT=1 git commit -m "wip"

git commit --no-verify does the same. If you prefer to run the checks by hand, orobox qa --staged works as a normal command outside the hook too.

The hook is written to the directory git actually runs hooks from — core.hooksPath when your checkout sets one, otherwise the repository’s own hooks directory, which covers worktrees and submodules. It records the absolute path of the orobox that installed it, because PhpStorm and the other GUI clients run hooks with a stripped-down PATH where a bare orobox is not found. If a pre-commit hook is already there, qa-init asks before replacing it and keeps the previous one as pre-commit.bak.

The PHPStan baseline

An existing codebase usually has more findings than anyone is going to fix before the next commit. A baseline is how PHPStan is turned on anyway: the findings that exist today are recorded in a file and ignored from then on, so a run fails only on what was added after it.

orobox qa --generate-baseline

The command runs PHPStan alone — that is the only tool with a baseline — writes phpstan-baseline.neon next to your phpstan.neon, and adds it to that file’s includes so the next orobox qa reads it. Both files belong in the repository: without the baseline every developer and every pipeline sees the whole backlog again.

Run it again after fixing some of the findings and the baseline shrinks to what is still there; PHPStan leaves the file it is regenerating out of its own configuration, so a refresh reports the tree as it is now rather than coming back empty.

Two combinations are refused rather than half-honoured:

PHPStan boots the real application kernel — phpstan-symfony enumerates the console commands, phpstan-doctrine asks Doctrine for the entity manager — so the loaders Orobox generates in vendor-bin/qa/tests/ boot the dotenv files first, the way bin/console does through symfony/runtime. The file they read is the one composer.json names in extra.runtime.dotenv_path (Oro renames it to .env-app), so %env(...)% placeholders resolve to the same values a console command would see. Without that, Doctrine falls back to its default host and the analysis dies with connection to server at "127.0.0.1" ... refused.

PHPStan reads a dumped debug container, so it needs the matching environment installed. On a developer’s machine the tools run in dev, the environment orobox install already set up, and warm var/cache/dev. In CI (the CI environment variable set) they run in test instead, matching the install the pipeline performs, and warm var/cache/test. The warmed cache is reused by later runs.

The shared vendor tree

The QA tools live in their own Composer tree, vendor-bin/qa, so their versions never touch the application’s. Every package both trees need — symfony/console, the Symfony contracts, psr/log, twig/twig and the rest — is installed once, in the application’s tree, and reached from the QA tree through the bootstrap Orobox writes to vendor-bin/qa/orobox/oro-autoload.php.

That is not a size optimization. A dumped Symfony debug container inline-requires vendor files by absolute path, and include_once dedupes on the path, not on the class name. With two copies installed, PHPStan boots the kernel, the container requires the application’s copy of a class the QA copy already declared, and the run dies with:

Fatal error: Cannot redeclare interface Symfony\Contracts\Service\ResetInterface

Twig fails the same way through a global function rather than a class — Cannot redeclare twig_var_dump(), declared by the Resources/debug.php that DebugExtension requires — which is why twig/twig is shared too. Twig dropped those functions in 3.9, so only Oro lines still on an older Twig reach that particular fatal.

Identical versions in both trees do not help — the paths still differ. So orobox qa-init (and the deploy pipeline’s QA stage) patches vendor-bin/qa/composer.json before Composer populates it: each shared package the application ships is written into replace at the patch line installed there (6.4.* for a v6.4.17) and removed from the QA requirements. A package the application does not ship keeps its pinned requirement and is installed in the QA tree as before.

The line rather than the point release, because Composer answers an unsatisfiable requirement by installing an older release of whoever asked for it, not by failing: an exact 6.4.16 against PHP-CS-Fixer’s symfony/options-resolver: ^6.4.24 quietly pulled in an algoritma/php-coding-standards old enough that its Composer plugin never wrote the shared ruleset, and the QA run then analysed without it. Symfony keeps its patch releases BC, so the line is a claim the application’s copy can honour.

Replacing the shared packages is not the whole fix, because the two trees always overlap somewhere Orobox cannot predict — twig/twig arrives in the QA tree through Twig-CS-Fixer, sebastian/diff through PHP-CS-Fixer, and both live in the application’s tree too. What decides the outcome is which tree the process resolves a duplicated class from, and that differs per tool:

The bootstrap reads which order to use from an environment variable Orobox sets on the PHPStan command line only, and it is loaded through autoload.files — the earliest hook Composer has, so no tool code can pin the process to the wrong copy first.

The patch edits your committed manifest, so commit the result together with the refreshed vendor-bin/qa/composer.lock. The run that first applies it re-resolves the QA tree once; later runs are lock-driven again.

Project configuration files

Every tool runs against a base configuration Orobox owns: the ruleset algoritma/php-coding-standards generates in vendor-bin/qa for the PHP tools, the one OroCommerce ships at the application root for the JS ones. A configuration file of the same name in your source root is merged on top of that base rather than replacing it, so adding one exclude no longer drops the whole shared standard. Your file wins wherever the two disagree.

algoritma/php-coding-standards writes its files from a Composer plugin event that not every release fires — the older releases the shared-vendor pins resolve to on Oro 6.0 install silently without one — so the QA install asks for them explicitly afterwards (composer algoritma-phpstan-create-config and its Rector and PHP-CS-Fixer siblings), and never overwrites a file that is already there.

File How it merges
phpstan.neon NEON includes: base first, yours last, then Orobox’s own excludes. Relative paths in either file keep resolving against that file.
rector.php Both configs are applied to the same RectorConfig, base first, then Orobox’s own skip list. Either shape works — a static function (RectorConfig $c) or a RectorConfig::configure() builder.
.php-cs-fixer.dist.php Rules merge key by key, yours winning, with Orobox’s PHPUnit override in between (see below). Risky rules are allowed when either side allows them. Finder, cache file and indentation come from your config, minus the generated sources below.
.twig-cs-fixer.php Rulesets merge rule by rule (keyed by rule class), yours winning. Finder and the remaining Config settings come from your config.
.eslintrc.yml, .stylelintrc.yml, .stylelintrc-css.yml extends: base first, yours last.
.eslintignore, .stylelintignore, .stylelintignore-css Not merged — yours replaces the base one. Ignore patterns resolve against the directory of the file holding them, so a merged copy would re-anchor every inherited pattern.

The merged file is generated into vendor-bin/qa/merged/ on every run; nothing is written to your checkout. When you ship no config of your own, the base one is used directly — except for rector.php, phpstan.neon and .php-cs-fixer.dist.php, which always go through the generated wrapper because Orobox has something of its own to add to each: a skip list for Rector, the excluded trees for PHPStan, the PHPUnit override and the same exclusion for PHP-CS-Fixer (see below).

Vendored trees are excluded from the analysis. vendor, vendor-oro and node_modules hold code the project did not write, so PHPStan’s excludePaths drops them and the PHP-CS-Fixer and Twig-CS-Fixer finders never walk them. vendor-oro is why this is a correctness matter and not a speed one: a bundle checkout holds OroCommerce’s whole installed tree under that name, so analysing the bundle root analysed the platform and every dependency it ships — thirteen findings against faker, gedmo, symfony/cache and friends, closing with “Result is incomplete because of severe errors”, none of it about the bundle.

php_unit_attributes is off. The shared standard enables PHP-CS-Fixer’s @PHPUnit100Migration:risky set, whose php_unit_attributes rule rewrites PHPUnit’s docblock annotations into PHPUnit 10 attributes and deletes the annotation it replaced — @dataProvider giveMeData becomes #[PHPUnit\Framework\Attributes\DataProvider('giveMeData')]. Every Oro line pins PHPUnit 9.6, which reads neither, so the rewritten test loses its data provider. Orobox turns the rule off between the base standard and your config: name it in your own .php-cs-fixer.dist.php to take it back once your tests run on PHPUnit 10. The override lives in the generated wrapper, and PHP-CS-Fixer always reads that wrapper — including in a checkout with no .php-cs-fixer.dist.php of its own, which is what the pipeline engine sees, since it installs the tools itself and never runs orobox qa-init.

Sources OroCommerce generates are excluded. src/AppKernel.php is written by the OroCommerce application skeleton and shipped again with every release, so reformatting it is work the next update undoes. Rector skips it and PHP-CS-Fixer’s finder drops it, on every install type. Nothing else is excluded: the skip list is the file OroCommerce owns, not a general opt-out.

A config Orobox cannot merge — a rector.php returning neither a closure nor a builder, a .php-cs-fixer.dist.php returning something that is not a PhpCsFixer\ConfigInterface — fails the tool with a message naming the file and what it actually returned. It is never silently ignored.

Running the checks in CI

orobox qa and orobox test can run on either of two engines:

--engine forces either one. The Dagger engine is available only for type: project, because the pipeline image (algoritmadev/orobox:<version>-project-latest) exists only for projects. It does not require a deploy section in .orobox.yaml: a project that never deploys with Orobox can still use it in CI.

In CI the sources are taken from the job’s own checkout (CI_PROJECT_DIR), never cloned again. Everything the repository ignores is excluded from the upload — that is not just an optimization: a local vendor/ carrying dev dependencies would otherwise be overlaid on top of the pipeline’s own.

The cache volume family defaults to the current branch (CI_COMMIT_REF_NAME, or git rev-parse --abbrev-ref HEAD locally), so a lint job reuses what the deploy of that branch built, and two branches with different migrations do not invalidate each other.

Reports

--report=gitlab makes every tool emit its own report; Orobox merges them into one document. Most of them speak GitLab Code Quality natively — ESLint through the eslint-formatter-gitlab package orobox qa-init installs, the linters themselves coming from OroCommerce’s node_modules, see QA Tools Initialization. Two are converted by Orobox: Rector, whose gitlab format prints a deprecation warning into the report and is being removed anyway, so it reports its own JSON; and stylelint, which is handed a formatter Orobox writes, because the published ones are each pinned to a stylelint major and the major that runs is OroCommerce’s choice.

With --report, the QA tools no longer stop at the first failure — a Code Quality report listing only PHPStan’s findings because Rector never ran would be worse than none. Every tool runs, and the command still exits non-zero if any of them found something.

The untouched per-tool files are kept under var/orobox/reports/raw/. When a merged report looks wrong, they are what says whether the tool or the merge is to blame.

GitLab CI

orobox ci-init generates the pipeline — see CI Initialization (ci-init). The generated jobs run orobox qa --report=gitlab, orobox test --report=gitlab and orobox deploy <stage>, and publish the reports at the paths those commands write to.

--skip-qa --skip-test on the generated deploy job is sound in a way it would not be otherwise: the lint and test jobs ran the same code, on the same commit, against the same cache scope.

Note that orobox deploy in CI also builds from CI_PROJECT_DIR rather than cloning, and the release therefore checks out the commit that was built (CI_COMMIT_SHA) rather than the stage’s configured ref. An explicit --ref still wins. This is what keeps the deployed revision equal to the tested one.

All of this depends on the Dagger engine’s cache surviving between jobs — see Cache warmth in CI.