Shared tooling that lets WordPress projects be checked, scoped and released the same way.
- PHP at the floor
composer.json'srequire.phpnames, and Composer 2. - For the Node baselines only, the Node and npm versions
package.json'senginesnames, and the tools itspeerDependencieslist.
The shared PHPCS profiles check code against the PHP and WordPress floors that php/quality-assurance/phpcs.base.dist.xml sets in testVersion and minimum_wp_version, and the PHPStan profile against the WordPress floor that php/quality-assurance/phpstan.dist.neon sets in WPCompat.requiresAtLeast; a project with lower floors lowers them as the scripts contract shows for PHPCS and its PHPStan section for PHPStan.
Add the VCS repository and require the package as a development dependency:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/ahegyes/wordpress-configs.git" }
],
"require-dev": {
"ahegyes/wordpress-configs": "dev-trunk"
}
}The root composer.json also declares minimum-stability: dev with prefer-stable: true, because this package requires the development branch of roave/security-advisories, which fails a development install on any known advisory in the dependency graph, and an alpha release of phpcompatibility/phpcompatibility-wp. Composer honors stability flags only in the root package. The root package also allows the two Composer plugins that register the PHPCS standards and the PHPStan extensions; a non-interactive install fails without them:
{
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"phpstan/extension-installer": true
}
}
}The Node baselines resolve through node_modules, so a project that uses them also installs the package with npm, as a Git dependency pinned to a commit SHA:
{
"devDependencies": {
"@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#<commit-sha>"
}
}Two profiles share php/quality-assurance/phpcs.base.dist.xml, which runs the full WordPress standard and PHPCompatibilityWP:
| Profile | File | Lints |
|---|---|---|
| Production | php/quality-assurance/phpcs.dist.xml |
Everything except tests/ |
| Tests | php/quality-assurance/phpcs.tests.dist.xml |
tests/, without the docblock and WordPress-runtime sniffs |
The production profile also requires @return on every named function and method docblock, @return void included, which WordPress-Docs leaves optional; constructors, destructors and docblocks that open with {@inheritDoc} need none.
Both profiles skip bin/, vendor/, vendor-prefixed/, node_modules/ and generated files. The patterns match case-sensitively, so a src/Vendor/ source directory is still linted. An index.php is linted like any other file, except that its file comment may be a // line, so a silence-is-golden placeholder stays clean.
Create a phpcs.dist.xml that extends the production profile and names the project's text domain and prefixes:
<?xml version="1.0"?>
<ruleset name="Project">
<rule ref="./vendor/ahegyes/wordpress-configs/php/quality-assurance/phpcs.dist.xml"/>
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="your-text-domain"/>
</property>
</properties>
</rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array">
<element value="yourprefix_"/>
<element value="YourVendor\YourPlugin"/>
</property>
</properties>
</rule>
</ruleset>Create a phpcs.tests.dist.xml beside it that extends the tests profile, php/quality-assurance/phpcs.tests.dist.xml, the same way. Keep the leading ./ in each ref. The scripts contract gives the two lint scripts and the two ways to lower the PHP and WordPress floors the profiles set.
php/quality-assurance/phpstan.dist.neon is the shared profile:
| File | Holds |
|---|---|
phpstan.base.dist.neon |
The rules that need no WordPress: the level, the missing #[\Override] check, the strict-rules settings and the nested vendor exclusions |
phpstan.dist.neon |
The base, plus the minimum WordPress version that johnbillion/wp-compat checks calls against and the types of documented globals |
phpstan.dist.neon.php |
Opt-in discovery of a plugin's conventional paths |
A global statement takes the types that the @global tags of its function's or method's docblock declare, as WordPress's documentation standard writes them, such as @global \wpdb $wpdb The database connection.; a global no tag declares stays mixed, and a statement with its own docblock keeps it. Code under a nested vendor/ or vendor-prefixed/ directory is scanned but not analyzed, so the code that uses it still resolves. This package requires php-stubs/wordpress-stubs; a project that scans or declares php-stubs/woocommerce-stubs, such as a WooCommerce extension, requires that itself.
Create a phpstan.dist.neon:
includes:
- vendor/ahegyes/wordpress-configs/php/quality-assurance/phpstan.dist.neon
parameters:
paths:
- your-plugin.php
- srcTo add a plugin's conventional paths without listing them, include phpstan.dist.neon.php as well. It adds functions-bootstrap.php, functions.php, uninstall.php, src/, includes/, models/, blocks/ and templates/ when they exist in the directory PHPStan runs from, and scans vendor-prefixed/ there; the entry file still goes under paths.
To analyze code with no WordPress loaded, include phpstan.base.dist.neon alone and list szepeviktor/phpstan-wordpress, johnbillion/wp-compat and swissspidy/phpstan-no-private under extra.phpstan/extension-installer.ignore in the root composer.json, so a WordPress function is an unknown symbol. A WordPress config in the same repository then includes those extensions itself, because PHPStan rejects the minimum WordPress version when wp-compat is not loaded:
includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon
- vendor/johnbillion/wp-compat/extension.neon
- vendor/swissspidy/phpstan-no-private/rules.neon
- vendor/ahegyes/wordpress-configs/php/quality-assurance/phpstan.dist.neonThe package installs phpstan-deprecation-rules, phpstan-strict-rules, szepeviktor/phpstan-wordpress (WordPress signatures and types), johnbillion/wp-compat (WordPress version compatibility) and swissspidy/phpstan-no-private (private WordPress APIs).
php/php-scoper/scoper-base.inc.php and php/composer/ScopePhpDependencies.php prefix the packages a WordPress plugin bundles with the plugin's own namespace, so plugins that bundle different versions of the same library run side by side.
Require php-scoper in the plugin itself, because Composer does not install this package's development requirements. This package conflicts with any php-scoper outside 0.18, whose behavior the pipeline relies on:
{
"require-dev": {
"humbug/php-scoper": "^0.18"
}
}Declare the scoping prefix, load the generated autoloader, and scope after every development install:
{
"autoload": {
"files": [
"vendor-prefixed/scoper-autoload.php"
]
},
"scripts": {
"post-autoload-dump": [
"DeepWebSolutions\\Config\\Composer\\ScopePhpDependencies::postAutoloadDump"
]
},
"extra": {
"scoping-prefix": "YourVendor\\YourPlugin\\Scoped"
}
}Create scoper.inc.php at the project root and name the packages to scope:
<?php declare( strict_types=1 );
return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/scoper-base.inc.php' )( __DIR__, 'dompdf/dompdf', 'php-di/php-di' );postAutoloadDump scopes only in development mode, so a production install keeps the scoped output it ships; composer dump-autoload scopes again by hand.
The scoping contract gives the packages a run scopes, what it prefixes, leaves global and rejects, and how a project declares host stubs, text domains, its own php-scoper settings and per-package recipes.
node/ holds one baseline per tool, which a project extends and overrides. The package declares the tools behind them as >= peer dependencies, floored at the versions it is tested against: @wordpress/scripts, @wordpress/eslint-plugin, @wordpress/stylelint-config, @playwright/test and typescript, whose defaults node/tsconfig.base.json builds on. typescript stays below 7, because typescript-eslint, which the ESLint base loads, refuses TypeScript 7.
The package depends on prettier as an alias of wp-prettier, so a consumer's wp-scripts format runs WordPress's fork of Prettier.
| Baseline | Holds |
|---|---|
node/tsconfig.base.json |
TypeScript's strict defaults, tightened for returns and switch cases and set up for code that wp-scripts bundles and tsc only type-checks |
node/eslint.config.base.mjs |
@wordpress/eslint-plugin's recommended flat config, with its unit-test and Playwright presets scoped to test globs, and console.warn and console.error allowed while other console calls warn |
node/stylelint.config.base.js |
@wordpress/stylelint-config/scss-stylistic, WordPress's SCSS and formatting rules, with generated trees ignored, selector-class-pattern off, and reports on needless, descriptionless and invalid-scope disable comments |
node/playwright.config.base.js |
A factory over the @wordpress/scripts Playwright config, taking the project's wp-env port |
TypeScript extends the base by path:
{
"extends": "@ahegyes/wordpress-configs/node/tsconfig.base.json",
"include": ["client/**/*"]
}The tsconfig takes effect only through the project's tsc script, conventionally lint:types, because wp-scripts builds TypeScript without type-checking it.
ESLint spreads the base:
import baseConfig from '@ahegyes/wordpress-configs/node/eslint.config.base.mjs';
export default [ ...baseConfig ];Stylelint spreads the base, because Stylelint ignores the ignoreFiles of a config loaded through extends:
const baseConfig = require( '@ahegyes/wordpress-configs/node/stylelint.config.base.js' );
module.exports = { ...baseConfig };Playwright calls the factory with the port the wp-env config serves on (8888 when it sets none) and spreads nested keys individually, or the top-level spread drops the WordPress defaults:
const { defineConfig } = require( '@playwright/test' );
const baseConfig = require( '@ahegyes/wordpress-configs/node/playwright.config.base.js' )( { port: 8888 } );
module.exports = defineConfig( { ...baseConfig, use: { ...baseConfig.use } } );The factory reads the specs from tests/EndToEnd/ and writes artifacts under tests/.cache/artifacts/. Its webServer runs npm run wp-env start on the given port, so a project that starts wp-env through another script spreads webServer and sets command.
.github/workflows/reusable-*.yml holds reusable workflows a project calls with workflow_call, pinned to a commit SHA:
| Workflow | Runs |
|---|---|
reusable-block-json-check.yml |
Validates every block.json against the WordPress schema |
reusable-codeql.yml |
CodeQL analysis per language |
reusable-php-lint.yml |
composer validate --strict and the named Composer scripts, each a parallel job |
reusable-php-syntax-check.yml |
php -l across PHP versions over chosen paths |
reusable-phpunit.yml |
A Composer test script, optionally inside the project's wp-env |
reusable-playwright-e2e.yml |
The Playwright suite against the project's wp-env |
reusable-plugin-check.yml |
WordPress Plugin Check against a built plugin directory |
reusable-release.yml |
Builds, tests and publishes a plugin release on GitHub, with an opt-in wp.org deploy; installs its WP-CLI commands from this package's php/release/composer.lock, so the plugin requires this package under require-dev |
reusable-scripts-styles-lint.yml |
The named npm scripts, each a parallel job |
reusable-supply-chain-audit.yml |
composer audit and npm audit of the committed lockfiles |
reusable-workflow-checks.yml |
actionlint and zizmor over the workflows |
The workflow reference gives every input, the permissions a caller grants, and an example call. The scripts contract names the scripts and files the workflows expect.
.distignore is the baseline of wp dist-archive exclusions: copy it to the project root and extend it with project-specific source paths. reusable-release.yml requires the file. It excludes tests, package-manager manifests, CI and IDE files, the agent, contributing and security documents, development configs such as every phpcs*.xml* ruleset, and secret material (.env*, root-anchored *.pem, *.key, id_rsa*, .npmrc, auth.json).
.editorconfig is WordPress core's with three changes: a tab is four columns wide, as PHPCS and Prettier measure it, every YAML extension indents with two spaces, and *.txt files keep LF line endings. Every other file, JSON and NEON included, indents with tabs, and Markdown keeps its trailing whitespace.
CONTRIBUTING.md gives the setup and the checks to run before a pull request, and the tests guide describes the test tiers, the opt-in mutation run and the fixtures.