Write Custom Sniffs for PHP-CS-Fixer | Web Formatter Blog

Write Custom Sniffs for PHP-CS-Fixer
A comprehensive guide to creating and implementing custom fixers for PHP-CS-Fixer to enforce your team's unique coding standards.
Introduction to PHP-CS-Fixer Custom Sniffs
PHP-CS-Fixer is a powerful tool that automatically fixes PHP coding standards issues. While it comes with numerous built-in fixers (often called "sniffs"), there are situations where you need to enforce company-specific or project-specific coding standards that aren't covered by the default rules.
This is where custom fixers come into play. By creating your own fixers, you can:
- Enforce unique coding standards specific to your organization
- Automate repetitive code style fixes
- Ensure consistency across large codebases
- Implement specialized formatting for domain-specific code patterns
In this guide, we'll walk through the process of creating, testing, and integrating custom fixers for PHP-CS-Fixer, with practical examples and best practices.
Prerequisites
Before diving into custom fixer development, make sure you have:
- PHP 7.4 or higher installed
- Composer installed for dependency management
- Basic understanding of PHP-CS-Fixer and its configuration
- Familiarity with PHP tokens and abstract syntax tree concepts
- PHPUnit for testing your fixers
If you're new to PHP-CS-Fixer, it's recommended to first get comfortable with using the tool and its built-in fixers before attempting to create custom ones.
Understanding PHP-CS-Fixer Fixers
Before creating your own fixer, it's essential to understand how PHP-CS-Fixer fixers work under the hood.
Types of Fixers
PHP-CS-Fixer categorizes fixers into several types:
- Risky fixers: May change code behavior
- Non-risky fixers: Only change code style without affecting behavior
- Configurable fixers: Accept configuration options
- Whitespace fixers: Focus on whitespace formatting
- PSR fixers: Implement PSR standards
- Symfony fixers: Implement Symfony coding standards
Your custom fixer will likely fall into one or more of these categories.
Fixer Structure
All fixers in PHP-CS-Fixer implement the{" "}
PhpCsFixer\Fixer\FixerInterface
interface, which
requires several methods:
interface FixerInterface
{
public function getName();
public function getDefinition();
public function isRisky();
public function supports(SplFileInfo $file);
public function fix(SplFileInfo $file, Tokens $tokens);
public function getPriority();
}
Most custom fixers extend the AbstractFixer
class,
which provides a base implementation for many of these methods.
Creating Your First Custom Fixer
Let's walk through the process of creating a simple custom fixer step by step.
Setting Up Your Environment
First, set up a project structure for your custom fixer:
# Create a new directory for your custom fixers
mkdir my-php-cs-fixers
cd my-php-cs-fixers
# Initialize a composer project
composer init
# Add PHP-CS-Fixer as a dependency
composer require --dev friendsofphp/php-cs-fixer
# Add PHPUnit for testing
composer require --dev phpunit/phpunit
Create a basic directory structure:
mkdir -p src/Fixer
mkdir -p tests/Fixer
Update your composer.json
to autoload your classes:
{
"name": "your-vendor/my-php-cs-fixers",
"description": "Custom PHP-CS-Fixer fixers",
"type": "library",
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"phpunit/phpunit": "^9.5"
},
"autoload": {
"psr-4": {
"YourVendor\\PhpCsFixer\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"YourVendor\\PhpCsFixer\\Tests\\": "tests/"
}
}
}
Basic Fixer Implementation
Let's create a simple fixer that ensures all class properties have
explicit visibility declarations. Create a file{" "}
src/Fixer/ExplicitPropertyVisibilityFixer.php
:
isTokenKindFound(T_CLASS);
}
/**
* {@inheritdoc}
*/
protected function applyFix(SplFileInfo $file, Tokens $tokens): void
{
foreach ($tokens as $index => $token) {
if (!$token->isGivenKind(T_VARIABLE)) {
continue;
}
// Check if this is a property (not a method parameter or a variable)
if (!$this->isClassProperty($tokens, $index)) {
continue;
}
// Check if property already has visibility
if ($this->hasVisibility($tokens, $index)) {
continue;
}
// Add 'public' visibility
$tokens->insertAt($index, [
new Token([T_PUBLIC, 'public']),
new Token([T_WHITESPACE, ' ']),
]);
}
}
/**
* Check if token at given index is a class property.
*/
private function isClassProperty(Tokens $tokens, int $index): bool
{
$prevIndex = $tokens->getPrevMeaningfulToken($index);
// If previous token is a semicolon or a curly brace, this might be a property
if ($tokens[$prevIndex]->equals(';') || $tokens[$prevIndex]->equals('{')) {
return true;
}
// Check for property type hint
if ($tokens[$prevIndex]->isGivenKind([T_STRING, T_NS_SEPARATOR, T_ARRAY])) {
return true;
}
return false;
}
/**
* Check if property already has visibility declaration.
*/
private function hasVisibility(Tokens $tokens, int $index): bool
{
$prevIndex = $tokens->getPrevMeaningfulToken($index);
while ($prevIndex !== null) {
if ($tokens[$prevIndex]->equals(';') || $tokens[$prevIndex]->equals('{')) {
// Reached the beginning of the statement without finding visibility
return false;
}
if ($tokens[$prevIndex]->isGivenKind([T_PUBLIC, T_PROTECTED, T_PRIVATE])) {
return true;
}
$prevIndex = $tokens->getPrevMeaningfulToken($prevIndex);
}
return false;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'YourVendor/explicit_property_visibility';
}
/**
* {@inheritdoc}
*/
public function getPriority(): int
{
// Should run before other property-related fixers
return 40;
}
}
This fixer scans for class properties without visibility declarations and adds the 'public' keyword.
Testing Your Fixer
Testing is crucial for fixers to ensure they work correctly.
Create a test file
tests/Fixer/ExplicitPropertyVisibilityFixerTest.php
:
doTest($expected, $input);
}
public function provideFixCases(): array
{
return [
'property without visibility' => [
' [
' [
'
Run the tests to verify your fixer works as expected:
./vendor/bin/phpunit tests/Fixer/ExplicitPropertyVisibilityFixerTest.php
Advanced Techniques
Once you've mastered the basics, you can explore more advanced techniques for creating powerful custom fixers.
Token Manipulation
PHP-CS-Fixer uses a token-based approach to manipulate code. Understanding token manipulation is key to creating effective fixers:
// Finding tokens
$classTokens = $tokens->findGivenKind(T_CLASS);
// Getting token positions
$startIndex = $tokens->getNextTokenOfKind($index, ['{']);
$endIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $startIndex);
// Inserting tokens
$tokens->insertAt($index, [new Token([T_PUBLIC, 'public'])]);
// Replacing tokens
$tokens[$index] = new Token([T_PRIVATE, 'private']);
// Removing tokens
$tokens->clearRange($startIndex, $endIndex);
// Analyzing token sequences
$prevToken = $tokens[$tokens->getPrevMeaningfulToken($index)];
Adding Configuration Options
To make your fixer more flexible, you can add configuration
options. Extend the ConfigurableFixerInterface
:
'public',
];
public function configure(array $configuration): void
{
parent::configure($configuration);
$this->configuration = array_merge($this->configuration, $this->configuration);
}
protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
{
return new FixerConfigurationResolver([
(new FixerOptionBuilder('default_visibility', 'The default visibility to use.'))
->setAllowedValues(['public', 'protected', 'private'])
->setDefault('public')
->getOption(),
]);
}
// Use $this->configuration['default_visibility'] in your fix method
}
Priority Handling
Fixers in PHP-CS-Fixer run in a specific order based on their priority. Higher priority fixers run first:
public function getPriority(): int
{
// This fixer should run before other property-related fixers
return 40;
// For reference:
// - Most whitespace fixers have priority around 0-20
// - PSR fixers typically have priority around 0
// - Structural fixers often have priority 10-50
}
Setting the right priority is important to avoid conflicts between fixers. If your fixer depends on another fixer having run first, give your fixer a lower priority.
Real-World Examples
Let's look at some practical examples of custom fixers that solve real-world problems.
Custom Array Formatting
This fixer ensures multi-line arrays have a specific format with one item per line:
isTokenKindFound(T_ARRAY) || $tokens->isTokenKindFound(CT::T_ARRAY_SQUARE_BRACE_OPEN);
}
protected function applyFix(SplFileInfo $file, Tokens $tokens): void
{
for ($index = 0; $index < $tokens->count(); ++$index) {
if (!$tokens[$index]->isGivenKind([T_ARRAY, CT::T_ARRAY_SQUARE_BRACE_OPEN])) {
continue;
}
// Find the opening and closing brackets
$openBraceIndex = $tokens[$index]->isGivenKind(T_ARRAY)
? $tokens->getNextTokenOfKind($index, ['('])
: $index;
$closeBraceIndex = $tokens[$index]->isGivenKind(T_ARRAY)
? $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openBraceIndex)
: $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $openBraceIndex);
// Check if this is a multi-line array
$isMultiLine = false;
for ($i = $openBraceIndex; $i < $closeBraceIndex; ++$i) {
if (str_contains($tokens[$i]->getContent(), "\n")) {
$isMultiLine = true;
break;
}
}
if (!$isMultiLine) {
continue;
}
// Format the array
$this->formatMultilineArray($tokens, $openBraceIndex, $closeBraceIndex);
}
}
private function formatMultilineArray(Tokens $tokens, int $openBraceIndex, int $closeBraceIndex): void
{
// Implementation of array formatting logic
// This would include:
// 1. Finding all commas
// 2. Ensuring a newline after each comma
// 3. Proper indentation for each item
}
public function getName(): string
{
return 'YourVendor/multiline_array_format';
}
public function getPriority(): int
{
return 30;
}
}
DocBlock Standards
This fixer enforces company-specific DocBlock standards:
isTokenKindFound(T_DOC_COMMENT);
}
protected function applyFix(SplFileInfo $file, Tokens $tokens): void
{
foreach ($tokens as $index => $token) {
if (!$token->isGivenKind(T_DOC_COMMENT)) {
continue;
}
$content = $token->getContent();
// Apply your company's DocBlock standards
$content = $this->enforceParamFormat($content);
$content = $this->enforceReturnFormat($content);
$content = $this->addCompanyTag($content);
// Replace the token if changes were made
if ($content !== $token->getContent()) {
$tokens[$index] = new Token([T_DOC_COMMENT, $content]);
}
}
}
private function enforceParamFormat(string $content): string
{
// Implement your param formatting rules
return preg_replace(
'/@param\s+([^\s]+)\s+\$([^\s]+)/',
'@param $1 $$2 Description',
$content
);
}
private function enforceReturnFormat(string $content): string
{
// Implement your return formatting rules
return preg_replace(
'/@return\s+([^\s]+)(?!\s)/',
'@return $1 Description',
$content
);
}
private function addCompanyTag(string $content): string
{
// Add company-specific tags if not present
if (!str_contains($content, '@company')) {
$content = str_replace(' */', ' * @company YourCompany' . PHP_EOL . ' */', $content);
}
return $content;
}
public function getName(): string
{
return 'YourVendor/company_docblock_standards';
}
public function getPriority(): int
{
return -10; // Run after other DocBlock fixers
}
}
Naming Conventions
This fixer enforces naming conventions for methods:
isTokenKindFound(T_FUNCTION);
}
protected function applyFix(SplFileInfo $file, Tokens $tokens): void
{
foreach ($tokens as $index => $token) {
if (!$token->isGivenKind(T_FUNCTION)) {
continue;
}
$nameIndex = $tokens->getNextMeaningfulToken($index);
if (!$tokens[$nameIndex]->isGivenKind(T_STRING)) {
continue;
}
$methodName = $tokens[$nameIndex]->getContent();
$fixedName = $this->fixMethodName($methodName);
if ($methodName !== $fixedName) {
$tokens[$nameIndex] = new Token([T_STRING, $fixedName]);
}
}
}
private function fixMethodName(string $name): string
{
// Convert snake_case to camelCase
if (str_contains($name, '_')) {
$name = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $name))));
}
// Ensure first character is lowercase (camelCase)
$name = lcfirst($name);
return $name;
}
public function getName(): string
{
return 'YourVendor/method_naming_convention';
}
public function getPriority(): int
{
return 0;
}
}
Distribution and Reuse
Once you've created your custom fixers, you'll want to distribute them in a way that makes them easy to reuse:
Creating a PHP-CS-Fixer Package
Package your fixers as a Composer package:
- Create a new repository with the standard PHP package structure
-
Add your custom fixers to a directory like{" "}
src/Fixer
-
Create a
composer.json
file with the necessary dependencies - Add tests for your fixers
- Publish to Packagist or your private repository
Implementing RuleSetProvider
Make your fixers available as a ruleset by implementing{" "}
RuleSetProviderInterface
:
true,
'YourVendor/line_length_limit' => true,
'YourVendor/company_docblock_standards' => true,
'YourVendor/method_naming_convention' => true,
];
}
}
Conclusion
Creating custom PHP-CS-Fixer rules allows you to enforce consistent coding standards that are specific to your project or organization's needs. By understanding the fixer architecture and token manipulation, you can automate the enforcement of even the most specific coding requirements.
Custom fixers not only help maintain code quality but also reduce the time spent on manual code reviews, allowing your team to focus on more important aspects of development. They ensure that all code follows the same standards, regardless of who wrote it, leading to a more cohesive and maintainable codebase.