Skip to content

About

Shared dev tooling — base configs, scoping helpers, reusable CI workflows — for the DWS WordPress framework + plugins.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

wordpress-configs

Shared PHPCS, PHPStan and php-scoper configurations, Node tool baselines and reusable GitHub Actions workflows for WordPress plugins, themes and sites.

Requirements

  • PHP at the floor composer.json's require.php names, and Composer 2.
  • For the Node baselines only, the Node and npm versions package.json's engines names, and the tools its peerDependencies list.

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 requiresAtLeast; a project with lower floors lowers them as the scripts contract shows for PHPCS and its PHPStan section for PHPStan.

Installation

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>"
  }
}

PHPCS

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.

PHPStan

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
        - src

To 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.neon

The 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).

Dependency scoping

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:

{
  "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' );

A name is a package name or an fnmatch() pattern, such as 'your-vendor/your-library-*'. The runtime dependencies of each named package follow from Composer's installed.json, through require and never require-dev, so naming a library scopes what it needs. A pattern matches every installed package, development tools included, so keep it as narrow as the packages it means. A name that starts with ! keeps the packages it matches unscoped, such as a polyfill whose global functions must stay reachable ('!symfony/polyfill-php80'), and the symbols they declare stay global too. Keep the scoped packages in require-dev, so a production install never loads their unprefixed copies, and require an unscoped package in require, so a production install still ships it.

A scope run:

  • copies every file of each scoped package to vendor-prefixed/<vendor>/<package>/, after emptying vendor-prefixed/ and refusing one that is a symbolic link;
  • leaves global the WordPress symbols of sniccowp/php-scoper-wordpress-excludes, Action Scheduler (as_* and ActionScheduler*), every symbol of the stubs files the project or a scoped package declares under extra.scoping-stubs, and their namespaces;
  • prefixes the strings that start with a scoped package's own namespace, which libraries use to build class names;
  • applies the recipe of each scoped package that has one in php/php-scoper/recipes/<vendor>/<package>.inc.php;
  • rewrites the text domain a scoped package declares under extra.scoping-text-domain to the project's extra.text-domain;
  • fails, naming each one, when the scoped code references a prefixed name it does not declare: a host symbol no stubs file declares, a dependency outside the scoped packages, or a string php-scoper took for a class name;
  • passes a function_exists() check of the prefixed name of a function it leaves global, PHP's own included, which php-scoper adds to every guard of that function and which is false while nothing declares the name;
  • writes vendor-prefixed/scoper-autoload.php, a class map over each scoped package's psr-4, psr-0 and classmap paths that honors exclude-from-classmap, followed by its files entries.

The run also fails when extra.scoping-prefix is missing or empty, when a name matches no installed package, when a scoped package requires one that is not installed, when a declared stubs file is missing, when a scoped package declares a text domain and the project declares none, when a declared text domain survives outside a string literal of its own, and when a recipe's target is gone.

The project or a scoped package declares the stubs files of the host symbols the scoped code calls, each as ./path/to/file.php relative to its composer.json, as vendor/package for that package's <package>.php, or as vendor/package:path/to/file.php:

{
  "extra": {
    "scoping-stubs": [
      "php-stubs/woocommerce-stubs",
      "php-stubs/woocommerce-stubs:woocommerce-packages-stubs.php",
      "./stubs/host-plugin.php"
    ]
  }
}

A package whose strings take the host plugin's text domain declares the domain it uses, and the project declares its own:

{
  "extra": {
    "scoping-text-domain": "your-library"
  }
}

scoper.inc.php returns an ordinary php-scoper config, so a project can add exclusions or patchers before returning it. Write namespace exclusions as anchored regular expressions, such as '/^YourVendor\\\\Name(?:\\\\|$)/i', because php-scoper matches a plain namespace string anywhere in a name.

A recipe returns php-scoper settings for one package, with paths relative to it: exclude-files, anchored exclude-namespaces, and replacements, a map of search to replace strings per file of the scoped code, in which {prefix} stands for the scoping prefix. A replacement whose search string is missing fails the run, so a library update that invalidates a recipe surfaces when scoping.

postAutoloadDump scopes only in development mode, so a production install keeps the scoped output it ships; composer dump-autoload scopes again by hand.

Node baselines

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, @wordpress/postcss-plugins-preset, @playwright/test and typescript, whose defaults node/tsconfig.base.json builds on.

Baseline Holds
node/tsconfig.base.json The deltas from TypeScript's defaults: react-jsx, noImplicitReturns, noFallthroughCasesInSwitch, isolatedModules, resolveJsonModule, skipLibCheck and noEmit
node/eslint.config.base.mjs @wordpress/eslint-plugin's recommended flat config, with its unit-test and Playwright presets scoped to test globs
node/stylelint.config.base.js @wordpress/stylelint-config/scss with generated trees ignored, selector-class-pattern off, and reports on needless, descriptionless and invalid-scope disable comments
node/postcss.config.base.js @wordpress/postcss-plugins-preset plus the production minifier @wordpress/scripts uses when a project has no PostCSS config
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/**/*"]
}

ESLint spreads the base:

import baseConfig from '@ahegyes/wordpress-configs/node/eslint.config.base.mjs';

export default [ ...baseConfig ];

Stylelint spreads the base and its rules, 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, rules: { ...baseConfig.rules } };

PostCSS re-exports the base; a project that needs another plugin appends it to the chain and keeps the minifier:

module.exports = require( '@ahegyes/wordpress-configs/node/postcss.config.base.js' );

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 Playwright factory sets testDir to tests/e2e/ and artifacts under tests/.cache/artifacts/, and returns the @wordpress/scripts webServer on the given port, which runs npm run wp-env start. It sets WP_BASE_URL and WP_ARTIFACTS_PATH only when they are unset, before loading the @wordpress/scripts config, which reads them once, so an exported value wins over port and a second call in the same process reuses the first configuration; a project that starts wp-env through its own script spreads webServer and sets command. For WordPress fixtures, import from @wordpress/e2e-test-utils-playwright; for accessibility scans, add @axe-core/playwright to the project's own devDependencies.

Reusable workflows

.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.

Distribution and editor files

.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, development configs such as every phpcs*.xml* ruleset, and secret material (.env*, root-anchored *.pem and *.key, id_rsa*, .npmrc, auth.json).

.editorconfig follows WordPress core: tabs by default, two spaces for YAML and JSON, trailing whitespace kept in Markdown, and CRLF line endings for *.txt.

Development

Install the dependencies:

composer install

Run the unit suite:

composer test

Run the PHP lint and the unit suite together:

composer quality-check

The tests guide describes the test tiers, the opt-in mutation run and the fixtures, and CONTRIBUTING.md covers the checks to run before a pull request.

About

Shared dev tooling — base configs, scoping helpers, reusable CI workflows — for the DWS WordPress framework + plugins.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages