apermo/apermo-coding-standards

Shared PHPCS coding standards for WordPress projects by Apermo.

Maintainers

Package info

github.com/apermo/apermo-coding-standards

Type:phpcodesniffer-standard

pkg:composer/apermo/apermo-coding-standards

Statistics

Installs: 439

Dependents: 3

Suggesters: 0

Stars: 0

Open Issues: 1

v2.6.1 2026-03-15 15:21 UTC

README

CI codecov Packagist Version PHP Version License

Shared PHPCS ruleset for WordPress projects. Combines WordPress Coding Standards, Slevomat type hints, YoastCS, and PHPCompatibility into a single reusable standard.

Requirements

  • PHP 7.4+

Installation

composer require --dev apermo/apermo-coding-standards

The Composer Installer Plugin automatically registers the standard with PHPCS.

Usage

Reference the Apermo standard in your project's phpcs.xml:

<?xml version="1.0"?>
<ruleset name="My Project">
    <file>.</file>
    <arg name="extensions" value="php"/>
    <arg value="-colors"/>
    <arg value="ns"/>

    <rule ref="Apermo"/>
</ruleset>

Then run:

vendor/bin/phpcs

What's Included

Standard Purpose
WordPress Coding Standards WordPress PHP conventions
Slevomat Coding Standard Type hint enforcement
YoastCS Additional quality rules
PHPCompatibility PHP version compatibility checks

Notable Opinions

  • Short array syntax ([]) enforced, long array syntax (array()) forbidden
  • Short ternary operators allowed
  • Yoda conditions disallowed
  • Short open echo tags (<?=) allowed
  • Type hints enforced for parameters, return types, and properties
  • Closures limited to 5 lines
  • Use statements must be alphabetically sorted
  • Unused imports are flagged
  • No more than 1 consecutive empty line (file-level, class-level, between functions)
  • require/require_once enforced over include/include_once
  • elseif enforced over else if
  • Unconditional if statements (if (true)) are errors
  • stdClass usage discouraged — new \stdClass() and (object) casts warned
  • Hook invocations (do_action, apply_filters) require PHPDoc blocks
  • Assignment alignment must be consistent within groups (all aligned or all single-space)
  • Nested closures and arrow functions are warned
  • exit() enforced over die and bare exit
  • Variable names must be at least 4 characters (configurable allowlist)
  • Closures must be static when not using $this
  • Trailing comma required in multi-line function calls
  • Functions limited to 50 lines, classes to 500 lines
  • Cognitive complexity flagged (warning > 15, error > 30)
  • Unused local variables flagged
  • Implicit array creation ($a[] = on undefined) flagged
  • ?? required instead of isset() ternary
  • Short type hints enforced in PHPDoc (int not integer)
  • null must be last in union types (string|null not null|string)
  • and/or operators disallowed (use &&/||)
  • Alternative control syntax (endif, endwhile) disallowed
  • register_rest_route() must include permission_callback
  • FILTER_SANITIZE_STRING and related deprecated constants flagged
  • add_option()/update_option() must include explicit autoload parameter

REST Permission Callback (Apermo.WordPress.RequireRestPermissionCallback)

Flags register_rest_route() calls without a permission_callback in the args array. Omitting this callback leaves the endpoint open to unauthenticated access — the #1 REST API security hole.

Only array literal args are checked. Variable or function call args are assumed correct (cannot verify statically).

// Bad — endpoint is publicly accessible
register_rest_route( 'myplugin/v1', '/items', [
    'methods'  => 'GET',
    'callback' => 'get_items',
] );

// Good — access is controlled
register_rest_route( 'myplugin/v1', '/items', [
    'methods'             => 'GET',
    'callback'            => 'get_items',
    'permission_callback' => function () {
        return current_user_can( 'read' );
    },
] );

Customization via phpcs.xml:

<!-- Downgrade to warning during migration -->
<rule ref="Apermo.WordPress.RequireRestPermissionCallback.Missing">
    <type>warning</type>
</rule>

No Filter Sanitize String (Apermo.PHP.NoFilterSanitizeString)

Flags deprecated PHP filter constants that give a false sense of security:

Constant Deprecated Since Suggested Replacement
FILTER_SANITIZE_STRING PHP 8.1 sanitize_text_field()
FILTER_SANITIZE_STRIPPED PHP 8.1 sanitize_text_field()
FILTER_SANITIZE_MAGIC_QUOTES PHP 7.4 wp_slash()
// Bad — deprecated, never actually sanitized
$name = filter_input( INPUT_POST, 'name', FILTER_SANITIZE_STRING );

// Good — proper sanitization
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );

Customization via phpcs.xml:

<!-- Downgrade to warning -->
<rule ref="Apermo.PHP.NoFilterSanitizeString.Found">
    <type>warning</type>
</rule>

Require Option Autoload (Apermo.WordPress.RequireOptionAutoload)

Warns when add_option() or update_option() is called without an explicit autoload parameter. The default behavior loads the option on every page request — a common performance footgun for options that are rarely needed.

// Bad — silently autoloaded on every page load
add_option( 'my_plugin_log', $data );
update_option( 'my_plugin_cache', $value );

// Good — explicit autoload control
add_option( 'my_plugin_log', $data, '', false );
update_option( 'my_plugin_cache', $value, false );

// Good — using named parameter (PHP 8.0+)
add_option( 'my_plugin_setting', $val, autoload: true );

Customization via phpcs.xml:

<!-- Upgrade to error -->
<rule ref="Apermo.WordPress.RequireOptionAutoload.MissingAutoload">
    <type>error</type>
</rule>

<!-- Disable entirely -->
<rule ref="Apermo.WordPress.RequireOptionAutoload.MissingAutoload">
    <severity>0</severity>
</rule>

Exit Usage (Apermo.PHP.ExitUsage)

Enforces exit() as the canonical form. Flags die, die(), and bare exit (without parentheses). Auto-fixable with phpcbf.

// Bad
die;
die();
die( 'message' );
exit;

// Good
exit();
exit( 1 );
exit( 'message' );

Customization via phpcs.xml:

<!-- Allow die (only flag bare exit) -->
<rule ref="Apermo.PHP.ExitUsage.DieFound">
    <severity>0</severity>
</rule>

Minimum Variable Name Length (Apermo.NamingConventions.MinimumVariableNameLength)

Warns on variable names shorter than 4 characters (excluding $). Common short names are allowed by default: i, id, key, url, row, tag, map, max, min, sql, raw.

// Bad
$pt = get_post_type();
$cb = function () {};

// Good
$post_type = get_post_type();
$callback = function () {};
$id = get_the_ID(); // allowed (in default allowlist)

Customization via phpcs.xml:

<!-- Change minimum length -->
<rule ref="Apermo.NamingConventions.MinimumVariableNameLength">
    <properties>
        <property name="minLength" value="3"/>
    </properties>
</rule>

<!-- Append to the default allowlist -->
<rule ref="Apermo.NamingConventions.MinimumVariableNameLength">
    <properties>
        <property name="allowedShortNames" type="array"
                  extend="true">
            <element value="hex"/>
            <element value="css"/>
        </property>
    </properties>
</rule>

Text Domain Validation

WordPress.WP.I18n text domain checking is active via the WordPress ruleset. Configure your project's text domain in your phpcs.xml:

<rule ref="WordPress.WP.I18n">
    <properties>
        <property name="text_domain" type="array">
            <element value="my-plugin"/>
        </property>
    </properties>
</rule>

Forbidden Nested Closures (Apermo.Functions.ForbiddenNestedClosure)

Closures and arrow functions nested inside other closures or arrow functions are warned. Extract the inner callback to a named function instead.

// Bad — nested closures
$fn = function () {
    $inner = function () {
        return 1;
    };
};

// Bad — nested arrow functions
$fn = fn() => fn() => 1;

// Good — extract to named function
function get_one(): int {
    return 1;
}
$fn = function () {
    $inner = get_one();
};

Customization via phpcs.xml:

<!-- Disable entirely -->
<rule ref="Apermo.Functions.ForbiddenNestedClosure.NestedClosure">
    <severity>0</severity>
</rule>

Commented-Out Code (Apermo.PHP.ExplainCommentedOutCode)

Commented-out PHP code in // comments must be preceded by a /** */ doc-block explanation starting with a recognized keyword:

Keyword Intent
Disabled Temporarily turned off, will be re-enabled
Kept Intentionally preserved as reference or rollback
Debug Diagnostic code kept for future troubleshooting
Review Seen but needs human review before deciding
WIP Work in progress, actively being developed

Examples:

/** Disabled: Plugin doesn't support PHP 8.3 yet. */
// add_action( 'init', 'my_func' );

/** Review (2026-02-14): Found during refactor, unclear if still needed. */
// register_post_type( 'legacy_type', $args );

An optional date in YYYY-MM-DD format can be added in parentheses after the keyword.

Supersedes Squiz.PHP.CommentedOutCode — that rule is disabled automatically.

Customization via phpcs.xml:

<!-- Add custom keywords -->
<rule ref="Apermo.PHP.ExplainCommentedOutCode">
    <properties>
        <property name="keywords" value="Disabled,Kept,Debug,Review,WIP,Deprecated"/>
    </properties>
</rule>

<!-- Downgrade to warning instead of error -->
<rule ref="Apermo.PHP.ExplainCommentedOutCode">
    <properties>
        <property name="error" value="false"/>
    </properties>
</rule>

Multiple Empty Lines (Apermo.WhiteSpace.MultipleEmptyLines)

No more than one consecutive empty line is allowed outside functions and closures. Inside functions, Squiz.WhiteSpace.SuperfluousWhitespace already enforces this.

Auto-fixable with phpcbf.

// Bad — 2+ consecutive empty lines at file/class level
$a = 1;


$b = 2;

// Good — at most 1 empty line
$a = 1;

$b = 2;

Customization via phpcs.xml:

<!-- Downgrade to warning -->
<rule ref="Apermo.WhiteSpace.MultipleEmptyLines">
    <type>warning</type>
</rule>

<!-- Disable entirely -->
<rule ref="Apermo.WhiteSpace.MultipleEmptyLines.MultipleEmptyLines">
    <severity>0</severity>
</rule>

Require Not Include (Apermo.PHP.RequireNotInclude)

include and include_once are forbidden because they silently continue on failure. Use require/require_once instead.

Not auto-fixable (changing include to require may alter behavior).

// Bad
include 'file.php';
include_once 'helpers.php';

// Good
require 'file.php';
require_once 'helpers.php';

Use // phpcs:ignore Apermo.PHP.RequireNotInclude to suppress when include is genuinely intended.

Separate error codes (IncludeFound, IncludeOnceFound) allow independent configuration:

<!-- Allow include but not include_once -->
<rule ref="Apermo.PHP.RequireNotInclude.IncludeFound">
    <severity>0</severity>
</rule>

Array Complexity (Apermo.DataStructures.ArrayComplexity)

Flags deeply nested or wide associative arrays that would benefit from typed objects (DTOs, value objects). Arrays with many string keys or deep nesting often indicate data structures that should be classes.

Two independent checks, each with a warning and error threshold:

Check Warning Error Default
Nesting depth TooDeep TooDeepError warn > 2, error > 3
Key count TooManyKeys TooManyKeysError warn > 5, error > 10

Only outermost arrays are checked — nested sub-arrays are not reported separately. Numeric arrays (without =>) are ignored entirely.

// Warning — 3 levels of associative nesting
$order = [
    'customer' => [
        'address' => [
            'city' => 'Berlin',
        ],
    ],
];

// Warning — 6 associative keys
$user = [
    'id'       => 1,
    'name'     => 'John',
    'email'    => 'john@example.com',
    'role'     => 'admin',
    'active'   => true,
    'verified' => true,
];

// OK — numeric arrays are ignored
$grid = [ [ 1, 2 ], [ 3, 4 ] ];

Customization via phpcs.xml:

<!-- Adjust thresholds -->
<rule ref="Apermo.DataStructures.ArrayComplexity">
    <properties>
        <property name="warnDepth" value="3"/>
        <property name="errorDepth" value="5"/>
        <property name="warnKeys" value="8"/>
        <property name="errorKeys" value="15"/>
    </properties>
</rule>

<!-- Disable key count checks entirely -->
<rule ref="Apermo.DataStructures.ArrayComplexity.TooManyKeys">
    <severity>0</severity>
</rule>
<rule ref="Apermo.DataStructures.ArrayComplexity.TooManyKeysError">
    <severity>0</severity>
</rule>

Global Post Access (Apermo.WordPress.GlobalPostAccess)

Flags global $post; inside functions, methods, closures, and arrow functions. Top-level (template) usage is allowed because the WordPress loop sets $post there. Functions should receive WP_Post or a post ID as a parameter.

// Bad — hidden dependency on global state
function get_title() {
    global $post;
    return $post->post_title;
}

// Good — explicit dependency
function get_title( WP_Post $post ) {
    return $post->post_title;
}

Implicit Post Function (Apermo.WordPress.ImplicitPostFunction)

Flags WordPress template functions called without an explicit post argument inside function scopes. These functions implicitly read the global $post, creating hidden dependencies.

Severity depends on what was passed, not which function:

Code Severity When
MissingArgument error Post param exists but no argument provided
NullArgument error Literal null passed as post argument
IntegerArgument warning Literal int or $var->ID passed
NoPostParameter error Function has no post param at all
// Bad — implicit global access inside function
function render() {
    $title = get_the_title();        // error: MissingArgument
    $id    = get_the_ID();           // error: NoPostParameter
    get_the_title( null );           // error: NullArgument
    get_the_title( $post->ID );      // warning: IntegerArgument
}

// Good — explicit post argument
function render( WP_Post $post ) {
    $title = get_the_title( $post );
    $id    = $post->ID;
}

Customization via phpcs.xml:

<!-- Downgrade to warning during migration -->
<rule ref="Apermo.WordPress.ImplicitPostFunction.MissingArgument">
    <type>warning</type>
</rule>

<!-- Disable NoPostParameter errors entirely -->
<rule ref="Apermo.WordPress.ImplicitPostFunction.NoPostParameter">
    <severity>0</severity>
</rule>

Forbidden stdClass (Apermo.PHP.ForbiddenObjectCast + SlevomatCodingStandard.PHP.ForbiddenClasses)

Discourages stdClass usage in favor of typed classes. Two rules work together:

  • Apermo.PHP.ForbiddenObjectCast warns on (object) casts
  • SlevomatCodingStandard.PHP.ForbiddenClasses warns on new \stdClass()

Both emit warnings (not errors) to allow gradual migration.

// Bad — untyped data bags
$config = (object) [ 'host' => 'localhost', 'port' => 3306 ];
$dto = new \stdClass();

// Good — typed classes
class DbConfig {
    public function __construct(
        public string $host,
        public int $port,
    ) {}
}
$config = new DbConfig( 'localhost', 3306 );

Customization via phpcs.xml:

<!-- Disable the (object) cast warning -->
<rule ref="Apermo.PHP.ForbiddenObjectCast.Found">
    <severity>0</severity>
</rule>

<!-- Disable the new stdClass() warning -->
<rule ref="SlevomatCodingStandard.PHP.ForbiddenClasses">
    <severity>0</severity>
</rule>

Hook Documentation (Apermo.Hooks.RequireHookDocBlock)

WordPress hook invocations (do_action, apply_filters, and their _ref_array and _deprecated variants) must be preceded by a PHPDoc block.

The sniff checks:

Code When
Missing No PHPDoc block before the hook call
MissingParam Hook passes arguments but doc block has no @param tags
MissingReturn apply_filters* call without a @return tag

All violations are errors.

// Bad — no documentation
do_action( 'my_plugin_init', $config );

// Good — documented hook
/**
 * Fires after plugin initialization.
 *
 * @param array $config Plugin configuration.
 */
do_action( 'my_plugin_init', $config );

// Bad — filter missing @return
/**
 * @param string $title The title.
 */
apply_filters( 'my_title', $title );

// Good — filter with @return
/**
 * Filters the display title.
 *
 * @param string $title The title.
 *
 * @return string Filtered title.
 */
apply_filters( 'my_title', $title );

Customization via phpcs.xml:

<!-- Disable entirely -->
<rule ref="Apermo.Hooks.RequireHookDocBlock">
    <severity>0</severity>
</rule>

<!-- Only require doc blocks, skip param/return checks -->
<rule ref="Apermo.Hooks.RequireHookDocBlock.MissingParam">
    <severity>0</severity>
</rule>
<rule ref="Apermo.Hooks.RequireHookDocBlock.MissingReturn">
    <severity>0</severity>
</rule>

Consistent Assignment Alignment (Apermo.Formatting.ConsistentAssignmentAlignment)

Consecutive assignment statements must use a consistent style: either all = operators are aligned to the same column, or all use a single space before =. Mixing styles within a group is warned. Auto-fixable with phpcbf — deviators are adjusted to match the majority style.

Groups where all operators are aligned but padded beyond the longest variable + 1 space are flagged as OverAligned errors (not auto-fixable).

A group of assignments breaks on: blank lines, non-assignment statements, or EOF.

Supersedes Generic.Formatting.MultipleStatementAlignment — that rule is disabled automatically.

// OK — all single-space
$a = 1;
$bb = 2;
$ccc = 3;

// OK — all aligned
$a   = 1;
$bb  = 2;
$ccc = 3;

// Warning (fixable) — mixed styles
$short = 1;
$veryLongName = 2;
$x            = 3;

// Error — over-aligned
$short     = 1;
$medium    = 2;
$long      = 3;

Customization via phpcs.xml:

<!-- Disable inconsistency warnings -->
<rule ref="Apermo.Formatting.ConsistentAssignmentAlignment.InconsistentAlignment">
    <severity>0</severity>
</rule>

<!-- Disable over-alignment errors -->
<rule ref="Apermo.Formatting.ConsistentAssignmentAlignment.OverAligned">
    <severity>0</severity>
</rule>

Consistent Double Arrow Alignment (Apermo.Arrays.ConsistentDoubleArrowAlignment)

Multi-line associative arrays must use a consistent => style: either all arrows are aligned to the same column, or all use a single space before =>. Mixing styles within an array is warned. Auto-fixable with phpcbf — deviators are adjusted to match the majority style.

Arrays where all arrows are aligned but padded beyond the longest key + 1 space are flagged as OverAligned errors (not auto-fixable).

Only outermost arrays are checked — nested sub-arrays are analyzed independently. Single-line arrays are skipped.

Supersedes WordPress.Arrays.MultipleStatementAlignment — that rule is disabled automatically.

// OK — all single-space
$config = [
    'host' => 'localhost',
    'port' => 3306,
    'database' => 'mydb',
];

// OK — all aligned
$config = [
    'host'     => 'localhost',
    'port'     => 3306,
    'database' => 'mydb',
];

// Warning (fixable) — mixed styles
$config = [
    'host' => 'localhost',
    'port' => 3306,
    'database_name' => 'mydb',
    'x'             => 'value',
];

// Error — over-aligned
$config = [
    'a'      => 1,
    'bb'     => 2,
    'ccc'    => 3,
];

Customization via phpcs.xml:

<!-- Disable inconsistency warnings -->
<rule ref="Apermo.Arrays.ConsistentDoubleArrowAlignment.InconsistentAlignment">
    <severity>0</severity>
</rule>

<!-- Disable over-alignment errors -->
<rule ref="Apermo.Arrays.ConsistentDoubleArrowAlignment.OverAligned">
    <severity>0</severity>
</rule>

Elseif Over Else If (PSR2.ControlStructures.ElseIfDeclaration)

else if must be written as elseif. Upgraded from the PSR2 default warning to an error.

Auto-fixable with phpcbf.

// Bad
if ( $a ) {
    // ...
} else if ( $b ) {
    // ...
}

// Good
if ( $a ) {
    // ...
} elseif ( $b ) {
    // ...
}

Custom Sniffs

Place custom sniffs in Apermo/Sniffs/<Category>/<SniffName>Sniff.php. PHPCS discovers them automatically.

Example: Apermo/Sniffs/Naming/FunctionPrefixSniff.php is referenced as Apermo.Naming.FunctionPrefix.

Contributing

Development

composer install       # Install dependencies
composer test          # Run PHPUnit tests
composer analyse       # Run PHPStan static analysis

Release Process

  1. Create a release/X.Y.Z branch from main
  2. Update CHANGELOG.md with the version heading and release date
  3. Open a PR — CI runs tests, PHPStan, and validates the changelog
  4. Merge the PR — GitHub Actions creates a draft release with the tag
  5. Review and publish the draft release on GitHub

AI Disclaimer

This project is developed with major assistance from Claude Code (Anthropic). Claude handles the bulk of the implementation — writing sniffs, tests, fixtures, CI workflows, and documentation — while the maintainer reviews, steers, and makes final decisions. Projects with stricter rules regarding the use of AI-generated code should refrain from forking or reusing code from this repository.

License

MIT