skip to content

How do you write a custom PHP_CodeSniffer 4.0 sniff, and what do its register() and process() methods do?

level: seniorimportance: should knowfreq 22%

answer

  1. implements PHP_CodeSniffer\Sniffs\Sniff
  2. Standard/Sniffs/Category/NameSniff.php
  3. register() returns token types
  4. process(File $phpcsFile, int $stackPtr)
  5. addFixableError plus a fixer changeset

basics

~10 s

A custom sniff is a class implementing PHP_CodeSniffer\Sniffs\Sniff inside a standard's Sniffs/Category folder: register() returns the token types it listens for, and process() inspects each matching token and reports violations through the File object.

solid answer

~30 s

You create a standard directory with a `ruleset.xml` and a class such as `Acme\Sniffs\Dates\ImmutableDateSniff` in `Acme/Sniffs/Dates/ImmutableDateSniff.php`; the path gives the sniff code `Acme.Dates.ImmutableDate`. It implements `PHP_CodeSniffer\Sniffs\Sniff`. `register()` returns token constants, for example `[T_NEW]`. `process(File $phpcsFile, int $stackPtr)` runs for every matching token; it reads `$phpcsFile->getTokens()`, walks nearby tokens with `findNext()`, and calls `addError($message, $stackPtr, 'Mutable')`. For an automatic fix, call `addFixableError()`, and if it returns true, wrap `$phpcsFile->fixer->replaceToken()` in `beginChangeset()`/`endChangeset()`. Public properties become ruleset-configurable. PHP_CodeSniffer 4.0 rejects sniffs that do not implement the interface or follow the naming convention, and tokenizes namespaced names as `T_NAME_QUALIFIED`-style tokens.

code

php · 26 lines
php
<?php
declare(strict_types=1);

namespace Acme\Sniffs\Dates;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Util\Tokens;

final class ImmutableDateSniff implements Sniff
{
    public function register(): array
    {
        return [T_NEW];
    }

    public function process(File $phpcsFile, int $stackPtr): void
    {
        $tokens = $phpcsFile->getTokens();
        $class = $phpcsFile->findNext(Tokens::EMPTY_TOKENS, $stackPtr + 1, null, true);
        if ($class === false || ltrim($tokens[$class]['content'], '\\') !== 'DateTime') {
            return;
        }
        $phpcsFile->addError('Use DateTimeImmutable instead of %s', $class, 'Mutable', [$tokens[$class]['content']]);
    }
}

go deeper

for a junior

Recall that a sniff is a class with register() listing tokens and process() reporting violations for each matching token.

for a middle

Explain how the directory layout produces the sniff code, how getTokens() and findNext() are used, and how public properties become ruleset settings.

for a senior

Show you can add a safe fixer with changesets, test it with .inc and .fixed files, and adapt a 3.x sniff to 4.0's name tokens.

for a principal

Judge when a house rule deserves a maintained custom sniff versus a configurable built-in sniff or a static-analysis rule.

## When a custom sniff is worth writing Built-in sniffs cover layout and many conventions, and some are configurable enough to express a house rule. For example, `Generic.PHP.ForbiddenFunctions` takes a `forbiddenFunctions` array property. A **custom sniff** is for a rule no configuration can express, such as "instantiate `DateTimeImmutable`, never `DateTime`". It still only looks at **tokens**; it does not know types, so rules that need type inference belong in a static analyser. ## Anatomy of a standard PHP_CodeSniffer finds sniffs by path and class name, and 4.0 **refuses** sniffs that break the convention: ``` Acme/ ruleset.xml Sniffs/ Dates/ ImmutableDateSniff.php -> class Acme\Sniffs\Dates\ImmutableDateSniff ``` - The class name must end in `Sniff`, and it must live in a `Sniffs\<Category>` namespace under the standard's name. - The sniff code is derived from that path: `Acme.Dates.ImmutableDate`. Each message adds a fourth part, such as `Acme.Dates.ImmutableDate.Mutable`. - The class must implement `PHP_CodeSniffer\Sniffs\Sniff`. 4.0 removed support for sniffs that do not. - `ruleset.xml` holds `<ruleset name="Acme">` plus any rules from other standards it builds on. A project uses it by referencing the directory, as in `<rule ref="./tools/Acme"/>`, or by registering the location with `phpcs --config-set installed_paths /path/to/standards` and then using `--standard=Acme`. ## The two methods | Method | Called | Job | |---|---|---| | `register(): array` | once, when the ruleset loads | return the token types to listen for, such as `T_NEW` or `T_FUNCTION` | | `process(File $phpcsFile, int $stackPtr)` | for every matching token in every file | inspect the code around `$stackPtr` and report violations | Inside `process()`: 1. `$tokens = $phpcsFile->getTokens();` gives the whole token array. Each entry has `type`, `code`, `content`, `line`, and for brackets and scopes, pointer keys such as `parenthesis_closer` or `scope_opener`. 2. Navigate with `$phpcsFile->findNext()` / `findPrevious()`, often skipping `Tokens::EMPTY_TOKENS`, which are whitespace and comments. 3. Report with `$phpcsFile->addError($message, $stackPtr, 'Code', $data)` or `addWarning()`. `%s` placeholders in the message are filled from `$data`. 4. Optionally return a stack pointer to skip ahead; returning `$phpcsFile->numTokens` skips the rest of the file. ## Making it fixable `addFixableError()` records the message and returns `true` only when the fixer is active, which means `phpcbf` is running. The fix is then applied through `$phpcsFile->fixer`: - `beginChangeset()` and `endChangeset()` group edits so they apply together; - `replaceToken($ptr, $content)`, `addContent()` and `addNewline()` change the token contents. `phpcbf` re-runs all sniffs after each pass, so a fix must leave code that the sniff no longer flags. Otherwise it loops into a fixer conflict. ## Reporting messages well - **Pick stable error codes.** The fourth part of the code (`Mutable` above) is what users put in `<exclude>` and `phpcs:ignore`. Renaming it later silently breaks their suppressions, which is exactly the pain PHP_CodeSniffer 4.0's own renamed codes caused. - **Use placeholders.** Pass variable parts through `$data` and `%s` in the message rather than concatenating, so the message template stays constant and a ruleset `<message>` override can still use the values. - **Point at the right token.** The `$stackPtr` you report on decides the line and column in the report, and it decides which line a trailing `phpcs:ignore` must sit on. - **Split distinct problems.** Give different problems different codes, so a project can exclude one without losing the others. ## Configurable behaviour Any **public property** on the sniff class can be set from a ruleset with `<property name="..." value="..."/>`, and array properties can be set with `<element>` tags. In 4.0 `true`, `false` and `null` values are cast consistently. ## 4.0 changes that affect sniff authors - Namespaced names are always tokenized with PHP 8's `T_NAME_QUALIFIED`, `T_NAME_FULLY_QUALIFIED` and `T_NAME_RELATIVE`, so a sniff that stitched names together from `T_STRING` and `T_NS_SEPARATOR` must be updated. A sniff matching `new \DateTime` therefore checks for both `T_STRING` and `T_NAME_FULLY_QUALIFIED`. - Method parameters gained types (`int $stackPtr`), and the static token arrays on `Tokens` are deprecated in favour of class constants such as `Tokens::EMPTY_TOKENS`. - The test base class is now `PHP_CodeSniffer\Tests\Standards\AbstractSniffTestCase`. It runs the sniff over a `.inc` fixture, compares the expected error lines, and requires a `.fixed` file whenever the fixer changes the fixture.

  • Why does a sniff written for 3.x miss `new \App\Clock\SystemClock` in 4.0?
    In 4.0, PHP_CodeSniffer tokenizes namespaced names with PHP 8's name tokens on every PHP version, so `\App\Clock\SystemClock` is one `T_NAME_FULLY_QUALIFIED` token, not a chain of `T_NS_SEPARATOR` and `T_STRING`. A sniff that only looks for `T_STRING` after `new` never matches it.
  • How do you make the sniff's behaviour configurable per project?
    Declare a public property on the sniff class, for example `public $forbiddenClasses = ['DateTime'];`, and read it in `process()`. A project then sets it in its ruleset under the sniff's `<rule ref>` with `<property name="forbiddenClasses" type="array">` and `<element value="..."/>` tags, adding `extend="true"` to keep the default entries.
  • How is a custom sniff tested?
    Extend `PHP_CodeSniffer\Tests\Standards\AbstractSniffTestCase`, put sample code in a `.inc` file next to the test, and return line-number-to-count maps from `getErrorList()` and `getWarningList()`. If the sniff is fixable, a matching `.fixed` file holding the expected output is required in 4.0.

saying these in an interview costs you the question

  • A sniff can use type information to know a variable's class
  • Any class with a process() method is picked up as a sniff
  • process() runs once per file rather than once per matching token
  • addFixableError() changes the code by itself
  • Namespaced names are still split into T_STRING and T_NS_SEPARATOR in 4.0