Skip to content
smallPict
Start free

Engineering18 min read

WordPress Plugin CI/CD: WPCS, PHPStan and PHP 7.4 to 8.5

WordPress plugin CI/CD in GitHub Actions: WPCS, PHPStan and automated tests on PHP 7.4 to 8.5, plus a pre-deploy gate before WordPress.org.

smallPict Engineering

Engineering

smallPict's WordPress plugin converts uploads to WebP and AVIF and serves them to visitors. When we tag a release, GitHub Actions commits it to the WordPress.org SVN repository, and from there it reaches every site that has the plugin installed. Sites with auto-updates turned on install it without anyone reviewing the change. The plugin declares Requires PHP: 7.4 and Requires at least: 5.9, so one build has to run on a shared host still on PHP 7.4 and on a container running PHP 8.5.

A fatal error in a plugin can take down wp-admin. An unescaped value on a settings page is an XSS bug on every site at once. A missing nonce check turns an admin action into a CSRF target. None of these can be patched quietly: the fix needs another release, and some sites keep the broken version until it arrives.

This post describes our WordPress plugin CI/CD pipeline: the WordPress Coding Standards (WPCS), PHPStan and automated tests that run on seven PHP versions, and the pre-deploy gate in front of WordPress.org. Everything below comes from the smallpict-wp repository at release 1.2.9.

Key takeaways

  • Every pull request into master runs WPCS, PHPStan and 9 automated test scripts on PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 and 8.5 in parallel in GitHub Actions.
  • WPCS enforces sanitized input, escaped output, nonces and prepared queries. PHPStan at level 5 finds type errors and dead branches from WordPress stubs, without running WordPress.
  • A release tag runs WPCS again on all seven PHP versions, and the job that commits to WordPress.org SVN starts only after all seven pass.

The WordPress plugin CI/CD pipeline at a glance

Two GitHub Actions workflows guard the plugin.

WorkflowRuns onJobsChecks
phpcs.yml (PHP Code Quality & Standards)push to master or develop, pull requests into masterone job per PHP version: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5WPCS, PHPStan, automated tests
deploy-all.yml (Deploy to WordPress.org)push of a v* tagWPCS on each of the 7 PHP versions, then deployWPCS, then build and SVN commit

Note on PHP Version Coverage: The screenshots in this post show our earlier CI runs covering PHP 7.4 through 8.3. Today, our GitHub Actions test matrix has expanded to cover PHP 7.4 through PHP 8.5, ensuring continuous forward-compatibility with modern PHP runtimes while maintaining zero regressions on legacy environments.

All tooling is a Composer dev dependency, so none of it ships to users:

JSON
"require-dev": {    "wp-coding-standards/wpcs": "^3.4.1",    "phpcsstandards/php_codesniffer": "^3.0",    "phpstan/phpstan": "^2.1",    "szepeviktor/phpstan-wordpress": "^2.0 || ^1.3.5",    "phpstan/extension-installer": "^1.4"}

Multi-version PHP matrix testing in GitHub Actions

What changes between PHP 7.4 and PHP 8.5

PHP 7.4 and PHP 8.5 run the same plugin source differently. Some of the changes that affect WordPress plugin code:

  • PHP 8.0 made many internal functions throw a TypeError for an argument of the wrong type, where 7.4 returned null with a warning. Code that passed an array to strlen() kept running on 7.4 and stops on 8.0.
  • PHP 8.0 changed how a number is compared with a non-numeric string. 0 == "abc" is true on 7.4 and false on 8.0, which silently flips some loose comparisons on option values.
  • PHP 8.0 added str_starts_with() and related functions. Our image processor calls str_starts_with(). On PHP 7.4 that works only because WordPress 5.9 and later ship a polyfill, which our Requires at least: 5.9 guarantees.
  • PHP 8.1 deprecated passing null to non-nullable parameters of internal functions. strtolower( wp_parse_url( $url, PHP_URL_HOST ) ) is fine on 7.4 and logs a deprecation on 8.1 when $url is relative and the host is null. Some hosts print deprecations into the page.
  • PHP 8.2 deprecated dynamic properties. Assigning to an undeclared property on one of your own classes logs a deprecation on every request.

Each of these shows up only when the code is analysed or executed on the PHP version that behaves differently. That is why the pipeline runs every pull request into master on all seven versions at once.

The GitHub Actions matrix

GitHub Actions WordPress plugin CI run: PHP matrix testing with five parallel WPCS and PHPStan jobs on PHP 7.4, 8.0, 8.1, 8.2 and 8.3, all passing

Figure 1: The PHP Code Quality & Standards workflow for the merge of the 1.2.0 work into master. Each PHP version is its own job, and the PHP 8.3 job shows the steps every job runs: PHPCS, PHPStan, then the automated tests.

Note on PHP Version Coverage: The screenshots above show our earlier CI runs covering PHP 7.4 through 8.3. Today, our GitHub Actions test matrix has expanded to cover PHP 7.4 through PHP 8.5, ensuring continuous forward-compatibility with modern PHP runtimes while maintaining zero regressions on legacy environments.

YAML
jobs:  code-quality:    name: WPCS & PHPStan (PHP ${{ matrix.php-version }})    runs-on: ubuntu-26.04    strategy:      matrix:        php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']    steps:      - uses: actions/checkout@v7      - uses: shivammathur/setup-php@v2        with:          php-version: ${{ matrix.php-version }}          tools: composer:v2, cs2pr          ini-values: memory_limit=2G          coverage: none      - run: composer install --prefer-dist --no-progress --no-interaction      - name: Run PHPCS (WordPress Coding Standards)        run: vendor/bin/phpcs -q --standard=phpcs.xml.dist --report=checkstyle | cs2pr      - name: Run PHPStan (WordPress Static Analysis)        run: vendor/bin/phpstan analyse --memory-limit=2G      - name: Run the standalone tests        run: |          for t in tests/test_*.php; do            echo "== $t"            php "$t" || exit 1          done

fail-fast keeps its default of true, so when one version fails, GitHub cancels the jobs still running. A broken pull request costs little, at the price of not seeing every version's result in the same run.

What the matrix proves, and what it does not

The three checks do not get the same value from the matrix. PHPStan analyses the code for the PHP version it runs on, and the tests execute the code on that version, so both can give a different answer per job. PHPCS works on tokens and in practice gives the same answer on all seven; in the matrix it mainly proves that the toolchain installs and runs on each version. Checking syntax and functions against a range of PHP versions in one pass is the job of PHPCompatibility, which our ruleset does not include yet.

Automating WordPress Coding Standards (WPCS)

The security sniffs that matter for a plugin

WPCS is a set of PHP_CodeSniffer sniffs written for WordPress. The rules that matter most for plugin security are in the WordPress ruleset itself:

  • WordPress.Security.ValidatedSanitizedInput flags any read of $_POST, $_GET, $_REQUEST or $_COOKIE that is not unslashed and sanitized.
  • WordPress.Security.EscapeOutput flags any echo or print of a value that has not passed through an escaping function such as esc_html(), esc_attr() or esc_url().
  • WordPress.Security.NonceVerification flags a handler that reads request data without verifying a nonce.
  • WordPress.DB.PreparedSQL and PreparedSQLPlaceholders flag a query built by string concatenation instead of $wpdb->prepare(), and placeholders that do not match their arguments.

Our WPCS ruleset

phpcs.xml.dist adds WordPress-Extra and WordPress-Docs, fixes the text domain for the translation sniffs, and requires every global function, class and constant to carry our prefix, so the plugin cannot collide with another plugin's names on the same site:

XML
<rule ref="WordPress">    <exclude name="Universal.Arrays.DisallowShortArraySyntax"/></rule><rule ref="WordPress-Extra"/><rule ref="WordPress-Docs"/>
<rule ref="WordPress.WP.I18n">    <properties>        <property name="text_domain" type="array">            <element value="smallpict"/>        </property>    </properties></rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">    <properties>        <property name="prefixes" type="array">            <element value="smallpict"/>            <element value="SmallPict"/>            <element value="SMALLPICT"/>        </property>    </properties></rule>

An AJAX handler that passes WPCS

This handler from the plugin restores offloaded media in batches. Every input boundary is handled in its first lines:

PHP
public function ajax_restore_offloaded() {    if ( ! check_ajax_referer( 'smallpict_restore_offloaded', 'nonce', false ) || ! current_user_can( 'manage_options' ) ) {        wp_send_json_error( array( 'message' => 'forbidden' ), 403 );    }    global $wpdb;    $after = isset( $_POST['after'] ) ? absint( wp_unslash( $_POST['after'] ) ) : 0;    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- admin action, the rows change on every call.    $ids = $wpdb->get_col(        $wpdb->prepare(            "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d",            '_smallpict_offloaded',            '1',            $after,            self::RESTORE_BATCH        )    );    // ...}

The nonce and the capability are checked together, because a valid nonce proves the request came from our admin page, not that the user may perform the action. $_POST['after'] is unslashed and cast with absint() before it reaches the query, and the query still goes through $wpdb->prepare() even though the value is already an integer.

Suppressions need a sniff name and a reason

The phpcs:ignore line above is deliberate. WPCS warns about direct database queries without object caching, which is good advice for front-end reads and wrong for an admin action whose rows change on every call. Our rule is that every ignore names the exact sniff and gives the reason after --. A bare // phpcs:ignore does not get through review.

In CI, PHPCS writes a checkstyle report and cs2pr turns it into annotations on the pull request diff, so a reviewer sees each violation on the line that caused it instead of in a log.

Catching logic flaws with PHPStan static analysis

WPCS reads code line by line. It knows an echo lacks an escaping call, but not what type a variable holds or whether a branch can ever run. PHPStan builds a model of the types flowing through each function and reports code that contradicts it: calling a method that does not exist, passing a string|false where a string is required, comparing values that can never be equal, conditions that are always true or always false.

Using PHPStan with WordPress without running WordPress

szepeviktor/phpstan-wordpress gives PHPStan the signatures of WordPress core functions and classes through stub files, plus a few WordPress-specific type extensions. phpstan/extension-installer registers it automatically. PHPStan reads those declarations and never loads WordPress or a database.

Our PHPStan configuration

YAML
parameters:    level: 5    paths:        - smallpict.php        - includes/        - admin/    treatPhpDocTypesAsCertain: false    bootstrapFiles:        - phpstan-bootstrap.php

Level 5 checks the type of every argument passed to a function or method, on top of the lower levels' checks for unknown classes, methods and properties, return types and dead code. treatPhpDocTypesAsCertain: false tells PHPStan not to treat our own @param docblocks as proof. WordPress passes whatever a filter returns, so a parameter documented as int can still arrive as a string from another plugin, and we want PHPStan to keep the defensive checks that handle that. The bootstrap file defines the plugin's constants (SMALLPICT_PATH, SMALLPICT_URL and others), which are normally set at runtime.

PHPStan WordPress static analysis log in GitHub Actions: WPCS step clean, PHPStan analysed 11 of 11 plugin files with "[OK] No errors"

Figure 2: The PHPCS and PHPStan steps of the PHP 8.3 job. PHPCS prints nothing when there is nothing to report. PHPStan analyses the 11 plugin files (the main plugin file and the 10 classes in includes/) and reports no errors.

A real PHPStan finding: line 626 of the image processor

On 2 October 2026 the pipeline failed on PHPStan with one error:

PHPStan catching a bug in a WordPress plugin: "Right side of && is always true" at includes/class-smallpict-image-processor.php line 626, rule booleanAnd.rightAlwaysTrue, job failed on PHP 8.2

Figure 3: The PHP 8.2 job stopped at the PHPStan step with booleanAnd.rightAlwaysTrue on line 626 of includes/class-smallpict-image-processor.php. At the time the plugin had 9 files in the analysed paths.

The code is the last step of a conversion. When the plugin's Output format setting is WebP or AVIF, the converted file becomes the attachment and the original upload is deleted to save disk space. The other choices, "AVIF, with WebP as a backup" and "Keep the original format", keep the original JPEG and PNG files. This was the deletion branch:

PHP
// C. Pure 1-to-1 Mode: Delete raw uncompressed source file to free 100% hosting space.if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path && file_exists( $main_file_path ) ) {    wp_delete_file( $main_file_path );}

PHPStan's claim was that file_exists( $main_file_path ) is always true at that point. The reason sits 150 lines earlier, at the top of the same method:

PHP
$main_file_path = get_attached_file( $attachment_id );if ( ! $main_file_path || ! file_exists( $main_file_path ) ) {    return new WP_Error( 'file_missing', 'File not found on disk.' );}

The method only continues when the file exists, and PHPStan carries that fact forward to line 626.

We did not take the finding at face value. file_exists() asks the filesystem, and the answer can change between two calls. PHPStan remembers the result of such a call by default (the rememberPossiblyImpureFunctionValues option), on the assumption that nothing in between changed the disk. Before deleting the check, we confirmed two things by reading the code:

  1. Nothing between the guard and line 626 deletes or moves the source file. The conversion writes each output to a temporary file and moves it to a new path, and the only wp_delete_file() calls on that path remove temporary files. The offload step, which does delete local files, runs after line 626.
  2. wp_delete_file() tolerates a missing file anyway. It passes the path through the wp_delete_file filter and calls @unlink(), which does nothing harmful when the file is already gone.

With both confirmed, the condition was dead code, and dead code in a deletion branch misleads the next reader into thinking the file can disappear mid-method. The fix removed it:

DIFF
-if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path && file_exists( $main_file_path ) ) {+if ( ( $is_single_webp || $is_single_avif ) && $canonical_path !== $main_file_path ) {     wp_delete_file( $main_file_path ); }

The finding was not a crash. Its value was what it said about the method: the code's assumptions had drifted from its structure, in the one branch that deletes user files. The lesson we took is narrower than "trust the analyser". An always-true or always-false report on a filesystem, network or time function deserves a manual check of what happens between the two calls before the condition goes.

Automated testing for a WordPress plugin without PHPUnit

The last step of each matrix job runs the files in tests/. These are not PHPUnit tests. Each file is a plain PHP script that defines minimal stubs for the WordPress functions it needs (get_option(), update_option(), sanitize_key() and so on), loads the plugin class under test, exercises it and exits non-zero on the first failure:

Shell
for t in tests/test_.php do  echo "== $t"  php "$t"  exit 1done

There are 9 such scripts today. They cover the paths where a mistake costs a customer data or money: the WebP and AVIF conversion pipeline and its fallbacks, image delivery, offloading and deleting files in cloud storage, restoring offloaded files in batches, the behaviour when an account stops paying, the storage quota, and uninstall. The uninstall test runs the real uninstall queries against an in-memory SQLite database and checks what is left:

PHP
check(    array( '_smallpict_cdn_base', '_smallpict_cdn_key', '_smallpict_offloaded', '_smallpictx', '_wp_attached_file' ) === $meta,    'offload markers and foreign meta remain (got ' . implode( ',', $meta ) . ')');

That assertion protects a decision that is easy to undo by accident. Uninstalling the plugin removes its settings but keeps the post meta that records which files live only in cloud storage, so a site that reinstalls the plugin can still find its images. A careless DELETE ... LIKE '_smallpict%' would remove those markers, and the test fails if it does.

We chose plain scripts over the WordPress PHPUnit test suite for speed and setup cost: they need no database server and no WordPress checkout, and all 9 run in under a second, so running them on seven PHP versions adds almost nothing to a pull request. The trade-off is that stubs only model what we wrote into them. Behaviour that depends on real WordPress core, such as the media library's own metadata handling, is not covered by these tests.

The pre-deploy gate to WordPress.org

Releases go out through a separate workflow. Pushing a tag such as v1.2.9 starts it, and the job that commits to WordPress.org SVN declares a dependency on the lint matrix:

YAML
jobs:  lint:    name: WPCS Check (PHP ${{ matrix.php-version }})    runs-on: ubuntu-26.04    strategy:      matrix:        php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']    steps:      - uses: actions/checkout@v7      - uses: shivammathur/setup-php@v2        with:          php-version: ${{ matrix.php-version }}      - run: composer install --prefer-dist --no-progress --no-interaction      - name: PHP CodeSniffer Check        run: vendor/bin/phpcs --standard=phpcs.xml.dist
  deploy:    name: Deploy    needs: lint    runs-on: ubuntu-26.04    permissions:      contents: read    steps:      - uses: actions/checkout@v7      - uses: shivammathur/setup-php@v2        with:          php-version: '7.4'      - name: Build WP.org Version        run: |          chmod +x build-wporg.sh          ./build-wporg.sh      - name: Deploy to WordPress.org        uses: 10up/action-wordpress-plugin-deploy@stable        with:          generate-zip: false        env:          SVN_USERNAME: ${{ secrets.SVN_USERNAME }}          SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}          SLUG: smallpict          BUILD_DIR: build-wporg/smallpict          ASSETS_DIR: .wordpress-org

needs: lint means the deploy job starts only when all seven lint jobs succeed. If WPCS fails on any version, GitHub skips deploy and no SVN commit happens. The build step runs on PHP 7.4, the oldest version we support. The job's token has read-only access to the repository; its only write is to WordPress.org SVN, with credentials held as repository secrets.

Pre-deploy gate in the Deploy to WordPress.org GitHub Actions workflow: WPCS Check passed on PHP 7.4, 8.0, 8.1, 8.2 and 8.3 before the Deploy job ran

Figure 4: The Deploy to WordPress.org workflow. The five WPCS Check jobs run first; the Deploy job below them runs only after all five pass.

The gate runs WPCS again on the tagged commit instead of trusting the earlier pull request run. A tag can point at any commit, including one pushed directly or one whose checks never finished, and the release workflow has no other way to know what it is shipping.

Running the CI checks locally

The Composer scripts wrap the same commands CI runs, so a failure can be reproduced before pushing:

Shell
composer installcomposer lint        # vendor/bin/phpcs --standard=phpcs.xml.distcomposer lint-fix    # vendor/bin/phpcbf: fixes spacing and formatting sniffscomposer phpstan     # vendor/bin/phpstan analysefor t in tests/test_.php do php "$t"  break done

To reproduce a failure from one matrix job, run the same commands under that PHP version, for example with the official image after composer install:

Shell
docker run --rm -v "$PWD":/app -w /app php:7.4-cli \  sh -c 'vendor/bin/phpstan analyse --memory-limit=2G && for t in tests/test_*.php; do php "$t" || exit 1; done'

phpcbf fixes only what a sniff can fix mechanically. Escaping, sanitizing and nonce findings need a person to decide which function fits the context, and we do not auto-fix them.

What the pipeline does not cover yet

Three gaps are on our list:

  1. The pre-deploy gate runs WPCS only. PHPStan and the automated tests run on pushes to master and develop and on pull requests, not on the release tag. Adding both steps to the lint job of the deploy workflow closes that.
  2. Our ruleset does not include PHPCompatibility. Adding phpcompatibility/phpcompatibility-wp with a testVersion of 7.4- would flag syntax and functions that do not exist on 7.4 in a single run.
  3. The tests use stubs rather than a real WordPress install. An integration job against WordPress 5.9 (our minimum) and the latest release would cover the core behaviour the stubs cannot.

FAQ

Why is matrix testing across PHP 7.4 to 8.5 critical for WordPress plugins?

WordPress.org delivers the same release to every site, and the plugin header (Requires PHP: 7.4) is the only filter on which PHP versions install it. Between 7.4 and 8.5, PHP changed how internal functions treat wrong argument types, how numbers compare with strings, and which patterns raise deprecations, so code that is correct on one version can fatal or log warnings on another. A CI run on a single PHP version only proves the plugin works on that version. Running PHPStan and the tests on each supported version catches those differences before the release reaches users.

Does WPCS replace traditional security audits?

No. WPCS checks patterns at the line where they occur: a request variable read without sanitizing, an echo without escaping, a handler without a nonce check, a query without $wpdb->prepare(). It cannot tell whether a handler checks the right capability, whether the escaping function fits the output context, whether a server-side request goes to a URL a user controls, or whether one user can reach another user's data. A phpcs:ignore comment can also silence a sniff. We treat WPCS as the floor that keeps known mistakes out, and review security-relevant changes by hand.

How does PHPStan handle WordPress core functions without executing code?

PHPStan never runs the code it analyses. It reads declarations and docblocks to build a model of types. For WordPress, szepeviktor/phpstan-wordpress supplies stub files: the signatures, parameter types and return types of WordPress core functions and classes, without their bodies. PHPStan reads those stubs as if they were WordPress, so it can check that a call to wp_delete_file() or get_attached_file() passes and receives the right types. Constants that WordPress or the plugin defines at runtime go in a bootstrap file, which PHPStan loads before analysis.

The plugin is on WordPress.org as smallPict. Installation and the Output format settings are covered in the installation guide and the image settings reference.

Written by smallPict Engineering

Engineering

We build image conversion and CDN delivery for websites and apps.

Try it on your own images.

20 MB a month free. About two minutes to set up on WordPress.

More on image performance, Core Web Vitals and cloud storage.

All articles