Usage

Back to the README

Two commands ship with the package, and the three underlying tools can always be run directly.

Command Use it to
festi-quality see how a project, some paths or a change stand across every tool
festi-phpcs-diff gate a merge request on the style of the lines it changed
phpcs, phpmd, phan run one tool with its own options

Run everything from the project root.

festi-quality

Runs phpcs with the Festi standard, PHPMD with the shipped ruleset and Phan with the project's .phan/config.php, reads the open issues SonarQube holds for the project, and prints one summary.

Choose what it looks at

vendor/bin/festi-quality                       # the whole project
vendor/bin/festi-quality src tests/Unit        # these directories
vendor/bin/festi-quality src/Billing/Invoice.php   # one file
vendor/bin/festi-quality --diff                # the lines this branch changed
vendor/bin/festi-quality --diff src            # the lines this branch changed under src
vendor/bin/festi-quality --base=origin/main    # the lines changed since that revision

"Changed" means three things together: what the branch committed since its merge base, what the working tree changed since HEAD, and every line of a file git does not track yet. .gitignore is honoured, so vendor/ is not taken for new work. The command is therefore as useful before a commit as it is in a pipeline.

Read the summary

Festi quality summary
Scope: whole project

  phpcs  35 issues    35 errors, 0 warnings, 20 auto-fixable
  phpmd  1 issue
  phan   0 issues
  sonar  1 issue      server analysis of 2026-10-02 at 7eb7db8e, NOT the revision checked out (705bbe1d)

phpcs by rule
      18  Generic.Arrays.DisallowLongArraySyntax.Found
       9  Festi.NamingConventions.EntityIdNaming.VariableIdNaming
       ...

The Scope: line says what the counts are counts of. Each tool then has one line, and three outcomes are kept apart because only the first means the code is clean:

Line Means
0 issues the tool ran and found nothing
SKIPPED the tool does not apply: no .phan/config.php, no sonar.projectKey, no SonarQube credentials, or a change with no PHP in it. The reason follows.
FAILED the tool is not installed, crashed, or the server refused. The reason follows.

Add --list to see every issue with its place:

phpcs issues
  src/Billing/Invoice.php:42  Generic.Arrays.DisallowLongArraySyntax.Found  Short array syntax must be used to define arrays

Options

Option Default Meaning
--diff off Look only at what changed since the base revision.
--base=<ref> the merge request base, then the default branch on origin Revision to diff against; implies --diff. A ref that does not exist is an error, never a fallback.
--list off After the counts, list every issue as file:line rule message.
--strict off Exit 1 when any tool reported an issue.
--only=<list> all four Tools to run: phpcs,phpmd,phan,sonar.
--ignore=<list> vendor/* Comma-separated patterns to leave out. PHPMD also always leaves out tests/*.
--format=<name> text json carries every rule of every tool, the scope, and with --list every issue. The text form shows ten rules per tool.

An option takes its value attached: --base=origin/main, not --base origin/main.

Exit codes

Code Means
0 The summary is complete. Without --strict this says nothing about what it found.
1 With --strict: at least one tool reported an issue, warnings included.
2 A tool could not produce a report, or the run could not start. This outranks 1: a broken tool may be hiding issues.

So the same command is a report by default and a gate when asked:

# Before a commit: everything, on what I just wrote.
vendor/bin/festi-quality --diff --strict --only=phpcs,phpmd,phan

Choose the gating tools with --only. SonarQube's count comes from the server's last analysis, not from your working tree, so it rarely belongs in a gate.

How each tool takes the scope

The tools take their input differently, so the scope is applied to every issue they report rather than trusted to each command line.

Tool Paths A change (--diff)
phpcs handed the paths handed the changed files. A message counts on a changed line; a missing type when the signature changed; a complexity message when a changed line is inside the function and the figure went up.
PHPMD handed the paths handed the changed files. A measure counts when a changed line is inside the class or method and the figure went up.
Phan analyses the whole program; issues outside the paths are dropped the same; issues off the changed lines are dropped
SonarQube asked for the paths as components of the project asked for the changed files, and not narrowed to lines

Three things follow that are worth knowing:

  • Phan takes as long on one file as on the project. It has to read the whole program to type one line of it. Use --only=phpcs,phpmd for a quick look.
  • phpcs and PHPMD run twice under --diff, once on the change and once on the base versions of the same files. See Did the change make it worse?
  • SonarQube is read, not run. The summary always names the revision the server analysed and says whether it is the one checked out. Under --diff it counts every open issue of the changed files and says so.

Did the change make it worse?

A measure of a whole function or class lands on any change that touches one. On its own that says the change was near the debt, not that it added to it. So under --diff, phpcs and PHPMD are run a second time on the changed files as they were at the base revision, and each measure is set beside its earlier figure:

At the base On the change Reported
under the limit, or the function did not exist over the limit yes, (new since origin/main)
12 15 yes, (was 12 at origin/main)
15 15 or lower no; counted in the tool's note
  phpcs  1 issue      before the comparison: 2 errors, 0 warnings, 0 auto-fixable; 1 measure(s) no worse than at origin/main left out

phpcs issues
  src/common/SourceTrait.php:120  Generic.Metrics.NestingLevel.TooHigh  Function's nesting level (5) exceeds 3 (was 4 at origin/main)

With --strict this is a gate that stops a change from adding complexity and lets a fix inside an old, complex function through. festi-phpcs-diff applies the same comparison to the complexity rules.

What is compared: the three complexity sniffs and every PHPMD rule. The base is the merge base of the base revision and HEAD, the same point the changed lines are counted from. A function is recognised by its name, so a renamed function is new. When the clone cannot reach the base revision, nothing is left out and the note says not compared with the base.

A project's own rulesets

When the project root holds phpcs.xml (or .phpcs.xml, or their .dist forms) or phpmd.xml (or phpmd.xml.dist), festi-quality uses it in place of the shipped rules and says so in the summary. See Configuration.

festi-phpcs-diff

The merge request gate for style. It runs phpcs over the files a branch touched and keeps only the messages about changed code, so a standard with a backlog can still block new violations.

vendor/bin/festi-phpcs-diff --ignore='vendor/**' ./
Option Default Meaning
--base=<ref> $CI_MERGE_REQUEST_DIFF_BASE_SHA, origin/$CI_DEFAULT_BRANCH, origin/HEAD, then main/master/develop Revision to diff against. A ref that does not exist is an error.
--standard=<name> Festi Standard to apply, or the path of a project ruleset.
--ignore=<list> none Comma-separated phpcs ignore patterns.

Exit codes: 0 clean, 1 errors in changed code, 2 the check could not run. Warnings are printed and do not fail the run.

A complexity error is about a whole function, so it is judged the way festi-quality --diff judges it: reported when the branch changed the function and the figure is higher than at the base revision, with what it was. When the clone cannot reach the base revision the error stays.

It differs from festi-quality --diff --strict --only=phpcs in one way: it fails on errors only, so a rule a project turned into a warning is shown and does not block.

--ignore patterns are matched against the absolute path. A pattern that reads as project-relative silences the whole tree when a parent directory happens to match it. If every changed file is ignored, the run passes and says so on stderr:

festi-phpcs-diff: nothing was checked — all 6 changed PHP file(s) matched
--ignore=vendor/**,Festi/**.

On a clone that cannot see the base revision (GitLab clones shallow by default), set GIT_DEPTH: 0 or pass --base.

The tools one by one

# Style, the whole tree.
vendor/bin/phpcs --standard=Festi -d memory_limit=1024M --extensions=php --ignore='vendor/**' ./

# Fix what phpcs can fix itself.
vendor/bin/phpcbf --standard=Festi --extensions=php --ignore='vendor/**' ./

# Class size and coupling.
vendor/bin/phpmd ./ text vendor/festi-team/coding-standard/phpmd/ruleset.xml \
    --exclude 'vendor/*,tests/*'

# Static analysis.
vendor/bin/phan --config-file .phan/config.php

Add --allow-polyfill-parser to the Phan call on a machine without ext-ast.