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
masterruns 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.
| Workflow | Runs on | Jobs | Checks |
|---|---|---|---|
phpcs.yml (PHP Code Quality & Standards) | push to master or develop, pull requests into master | one job per PHP version: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 | WPCS, PHPStan, automated tests |
deploy-all.yml (Deploy to WordPress.org) | push of a v* tag | WPCS on each of the 7 PHP versions, then deploy | WPCS, 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:
"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
TypeErrorfor an argument of the wrong type, where 7.4 returnednullwith a warning. Code that passed an array tostrlen()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"istrueon 7.4 andfalseon 8.0, which silently flips some loose comparisons on option values. - PHP 8.0 added
str_starts_with()and related functions. Our image processor callsstr_starts_with(). On PHP 7.4 that works only because WordPress 5.9 and later ship a polyfill, which ourRequires at least: 5.9guarantees. - PHP 8.1 deprecated passing
nullto 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$urlis relative and the host isnull. 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

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.
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 donefail-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.ValidatedSanitizedInputflags any read of$_POST,$_GET,$_REQUESTor$_COOKIEthat is not unslashed and sanitized.WordPress.Security.EscapeOutputflags anyechoorprintof a value that has not passed through an escaping function such asesc_html(),esc_attr()oresc_url().WordPress.Security.NonceVerificationflags a handler that reads request data without verifying a nonce.WordPress.DB.PreparedSQLandPreparedSQLPlaceholdersflag 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:
<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:
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
parameters: level: 5 paths: - smallpict.php - includes/ - admin/ treatPhpDocTypesAsCertain: false bootstrapFiles: - phpstan-bootstrap.phpLevel 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"](/blog-assets/02_phpcs_phpstan_log.png)
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:

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:
// 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:
$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:
- 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. wp_delete_file()tolerates a missing file anyway. It passes the path through thewp_delete_filefilter 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:
-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:
for t in tests/test_.php do echo "== $t" php "$t" exit 1doneThere 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:
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:
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-orgneeds: 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.

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:
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 doneTo reproduce a failure from one matrix job, run the same commands under that PHP version, for example with the official image after composer install:
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:
- The pre-deploy gate runs WPCS only. PHPStan and the automated tests run on pushes to
masteranddevelopand on pull requests, not on the release tag. Adding both steps to thelintjob of the deploy workflow closes that. - Our ruleset does not include PHPCompatibility. Adding
phpcompatibility/phpcompatibility-wpwith atestVersionof7.4-would flag syntax and functions that do not exist on 7.4 in a single run. - 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.