LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Blog Overview /WordPress Plugins & Tricks/Tutorials / Plugin Check locally and in CI: findings...
Read this article in other languages:

Plugin Check locally and in CI: findings before the review

Plugin Check locally and in CI: findings before the review

Management Summary

Plugin Check is a tool for finding common problems in WordPress extensions before publication. It can check a development copy or run automatically after changes. Its purpose is to catch issues such as missing licence information or unsafe handling of displayed data early, reducing avoidable review work. The article explains how to use its findings meaningfully. The tool supports developers, but an empty report is neither a guarantee of security nor a promise of acceptance into the plugin directory.

Contents
  1. What Plugin Check examines
  2. Running it locally: admin screen and WP-CLI
  3. A sample plugin with built-in mistakes
  4. What Plugin Check reports for it
  5. The corrected version
  6. Plugin Check in GitHub Actions
  7. Limits and open points
  8. Questions and answers
  9. Sources

Plugin Check finds common problems before a WordPress.org submission: missing licence information, unsafe output or incorrectly assigned translations. The tool is also called PCP. A finding identifies something to investigate; a clean run proves neither security nor acceptance into the directory.

This article covers the WordPress administration screen, the command line and GitHub Actions. CI means that GitHub automatically starts checks after a code change. Technical details refer to Plugin Check 2.1.0 and action v1.1.9. A deliberately faulty example and an improved version illustrate the differences.

What Plugin Check examines

Plugin Check 2.1.0 requires at least WordPress 6.3 and PHP 7.4. The earlier WordPress 7.1.2 example environment is a historical test setup, not a current update recommendation. New test environments need a current supported WordPress version. “Tested up to” records a test version, not complete compatibility.

Version 2.1.0 registers 34 checks. Each has a short technical name such as late_escaping. Static checks read program files without executing the examined plugin code. Many use PHP_CodeSniffer, or PHPCS; individual rules are called sniffs. Escaping protects output for its destination, such as HTML text or an HTML attribute, so characters in a value do not become unwanted code.

Runtime checks observe an activated plugin while it runs. Plugin Check uses separate test tables, but these are not a complete security boundary: plugin code can still access files or external services. Checks therefore belong in a disposable local installation without real customer data or production credentials. The five runtime checks in 2.1.0 concern size, scope and loading behavior of scripts and stylesheets.

Category (slug)Examples of checks in 2.1.0
General (general)i18n_usage, php_error_reporting, ai_provider
Plugin Repo (plugin_repo)plugin_readme, plugin_header_fields, file_type, offloading_files, setting_sanitization, prefixing, plugin_review_phpcs, trademarks
Security (security)late_escaping, direct_file_access, safe_redirect, direct_db_queries, direct_db
Performance (performance)enqueued_scripts_in_footer, performant_wp_query_params, enqueued_scripts_scope, non_blocking_scripts
Accessibility (accessibility)none: the category is defined, but no built-in check of 2.1.0 is assigned to it

Every Security check in 2.1.0 also belongs to plugin_repo. That category already includes output and direct-file-access checks. The FAQ says that directory submissions typically need to pass every Plugin Repo check. Other categories add further checks. Approval still requires review by the team.

ERROR and WARNING are result types, with a numerical severity value. This classification does not reliably rank actual security risk. A warning about missing form checks can identify a real vulnerability. A URL value used only for display does not require a nonce merely because it comes from a URL. Processing, output and possible data changes determine the risk. A nonce helps resist cross-site requests; a permission check determines which account may perform an action.

Running it locally: admin screen and WP-CLI

After installation and activation, Tools → Plugin Check provides selectors for plugins, categories and result types. Access requires permission to manage plugins. Results identify file, line, column, type, code and explanation. Version 1.8.0 added CSV, JSON and Markdown export. Every command below refers to the local test installation.

WP-CLI controls WordPress from the command line. wp plugin check accepts a plugin name, path or ZIP URL. Only static checks run by default. Runtime checks require --require to load cli.php before WordPress starts, and the examined plugin must be active. Without these prerequisites, some checks are absent. The absence of runtime findings then provides no evidence of correct operation.

Shell
# Local disposable test installation; version used for this article.
wp plugin install plugin-check --version=2.1.0 --activate

# All static checks for wp-content/plugins/lw-hello-banner.
wp plugin check lw-hello-banner

# Include runtime checks (plugin must be active): load cli.php before WordPress boots.
wp plugin check lw-hello-banner --require=./wp-content/plugins/plugin-check/cli.php

# Limit the run to categories or to single checks.
wp plugin check lw-hello-banner --categories=plugin_repo,security
wp plugin check lw-hello-banner --checks=late_escaping,i18n_usage

# Findings as one JSON array; no findings produces a success line.
wp plugin check lw-hello-banner --ignore-warnings --format=strict-json --fields=file,line,column,type,code,message

# Show result codes, then ignore one specific code.
wp plugin check lw-hello-banner --format=csv --fields=code,message
wp plugin check lw-hello-banner --ignore-codes=textdomain_mismatch

# Checks for an update of an existing directory plugin.
wp plugin check lw-hello-banner --mode=update

# What is available in the installed version.
wp plugin list-checks --format=csv
wp plugin list-check-categories

The main wp plugin check options in version 2.1.0:

FlagEffect
--checks=, --exclude-checks=run only the listed check slugs, or skip them
--categories=limit the run to categories, comma-separated
--ignore-codes=hide individual result codes such as textdomain_mismatch
--ignore-warnings, --ignore-errorshide one of the two result types
--format=table (default), csv, json, ctrf and the variants strict-table, strict-csv, strict-json, strict-ctrf
--fields=choose columns, for example code,message
--exclude-directories=, --exclude-files=skip paths in file-based scans; .git, vendor, vendor_prefixed, vendor-prefixed and node_modules are excluded by default
--severity=, --error-severity=, --warning-severity=show only results at or above a severity
--mode=new (default) or update; in update mode an outdated “Tested up to” is reported as a warning instead of an error
--slug=override the slug used for text domain and readme comparisons

A check name selects a test; a result code identifies a finding. For example, --checks=i18n_usage selects translation checks. --ignore-codes=WordPress.WP.I18n.TextDomainMismatch hides matching findings instead. --format=csv --fields=code,message lists codes and explanations.

The CLI code in 2.1.0 does not automatically return a failure status when it finds problems. Exit code 0 therefore does not mean “no findings”. An automated approval step must evaluate the results and must not treat missing or unreadable output as success. The GitHub Action includes its own result processing.

With --format=json, each file has a FILE: … line followed by a JSON array. The whole output is not one JSON document. With findings, --format=strict-json produces one combined array. Default columns omit filenames; --fields=file,line,column,type,code,message adds them. Without findings, a success line still appears instead of an empty array. Scripts must distinguish both cases explicitly.

A sample plugin with built-in mistakes

The deliberately faulty plugin belongs in wp-content/plugins/lw-hello-banner/. Here, the folder name is the slug. The text domain assigns translations to the plugin and should also be lw-hello-banner. The example prints a greeting, reads a name from the URL and provides a note field. Deliberate problems include raw output, missing licence information and an externally loaded script. A CDN is an external file-delivery service. This faulty version is only for reading and checking in a disposable environment.

PHP
<?php
/**
 * Plugin Name: LW Hello Banner
 * Description: Shows a greeting banner at the top of the front page.
 * Version:     0.1.0
 * Author:      Lukas Wojcik
 * Text Domain: hello-banner
 */

// Intentionally faulty example for Plugin Check. Do not use on a live site.

error_reporting( E_ALL );

function lw_hello_banner_register_setting() {
	register_setting( 'general', 'lw_hello_banner_note' );

	add_settings_field(
		'lw_hello_banner_note',
		__( 'Banner note', 'hello-banner' ),
		'lw_hello_banner_field',
		'general'
	);
}
add_action( 'admin_init', 'lw_hello_banner_register_setting' );

function lw_hello_banner_field() {
	echo '<input type="text" name="lw_hello_banner_note" value="' . get_option( 'lw_hello_banner_note' ) . '">';
}

function lw_hello_banner_assets() {
	wp_enqueue_script( 'lw-hello-banner', 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js' );
}
add_action( 'wp_enqueue_scripts', 'lw_hello_banner_assets' );

function lw_hello_banner_render() {
	$name = isset( $_GET['lw_name'] ) ? $_GET['lw_name'] : 'Guest';
	$note = get_option( 'lw_hello_banner_note', '' );

	echo '<div class="lw-hello-banner"><p>' . sprintf( __( 'Hello, %s!', 'hello-banner' ), $name ) . ' ' . $note . '</p></div>';
}
add_action( 'wp_body_open', 'lw_hello_banner_render' );

What Plugin Check reports for it

The table connects typical findings from the Plugin Check 2.1.0 rules to the example problems. Counts and exact output depend on the environment, version and selected checks. This is not a guaranteed current measurement.

Result codeCheck (category)TypeCause in the sample
no_plugin_readmeplugin_readme (plugin_repo)ERRORno readme.txt
plugin_header_no_licenseplugin_header_fields (plugin_repo)ERRORno License header
textdomain_mismatchplugin_header_fields (plugin_repo)WARNINGheader says hello-banner, slug is lw-hello-banner
missing_direct_file_access_protectiondirect_file_access (security, plugin_repo)ERRORfunctions and hooks without an ABSPATH guard
WordPress.WP.I18n.TextDomainMismatchi18n_usage (general, plugin_repo)ERROR__() calls with the wrong text domain
WordPress.WP.I18n.MissingTranslatorsCommenti18n_usage (general, plugin_repo)ERROR%s without a translators: comment
PluginCheck.CodeAnalysis.PHPErrorReporting.DirectErrorReportingCallphp_error_reporting (general)WARNINGerror_reporting( E_ALL )
WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reportingplugin_review_phpcs (plugin_repo)WARNINGerror_reporting( E_ALL )
PluginCheck.CodeAnalysis.SettingSanitization.register_settingMissingsetting_sanitization (plugin_repo)ERRORregister_setting() without a third argument
WordPress.Security.EscapeOutput.OutputNotEscapedlate_escaping (security, plugin_repo)ERRORget_option(), __(), $name and $note in echo
PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContentoffloading_files (plugin_repo)ERRORscript URL on cdn.jsdelivr.net
WordPress.WP.EnqueuedResourceParameters.MissingVersion, .NotInFooterenqueued_scripts_in_footer (performance)WARNINGno version, no fifth argument
WordPress.Security.NonceVerification.Recommendedplugin_review_phpcs (plugin_repo)WARNING$_GET['lw_name'] without a Nonce check
WordPress.Security.ValidatedSanitizedInput.MissingUnslash, .InputNotSanitizedplugin_review_phpcs (plugin_repo)WARNING$_GET['lw_name'] used raw
Plugin Check: inspect, fix, repeat
A clean check does not prove security or directory approval.

Several checks can report one underlying cause. The header check compares the text domain in the file header with the slug; the i18n check examines translation calls. error_reporting() can also trigger multiple rules. Nonce and input rules come from plugin_review_phpcs, so selecting only security misses some security-related findings.

Static and runtime checks examine different properties. An external script can be flagged simply for its remote URL. For a local script, the pages loading it also matter. A homepage-only banner does not need its script on every other page. No finding is a substitute for checking that behavior.

The corrected version

The improved version contains a main file, readme.txt and local JavaScript in assets/. The header states the licence and minimum versions. The ABSPATH guard prevents direct execution outside WordPress. The note setting gets a sanitization function; HTML output is escaped immediately before printing. The script loads only on the front page. defer postpones execution until the HTML document has been parsed. This loading-argument form has existed since WordPress 6.3.

PHP
<?php
/**
 * Plugin Name:       LW Hello Banner
 * Description:       Shows a greeting banner at the top of the front page.
 * Version:           0.2.0
 * Requires at least: 6.3
 * Requires PHP:      7.4
 * Author:            Lukas Wojcik
 * License:           GPLv2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       lw-hello-banner
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'LW_HELLO_BANNER_VERSION', '0.2.0' );

/**
 * Registers the banner note setting and its field on Settings > General.
 */
function lw_hello_banner_register_setting() {
	register_setting(
		'general',
		'lw_hello_banner_note',
		array(
			'type'              => 'string',
			'sanitize_callback' => 'sanitize_text_field',
			'default'           => '',
		)
	);

	add_settings_field(
		'lw_hello_banner_note',
		__( 'Banner note', 'lw-hello-banner' ),
		'lw_hello_banner_field',
		'general',
		'default',
		array( 'label_for' => 'lw_hello_banner_note' )
	);
}
add_action( 'admin_init', 'lw_hello_banner_register_setting' );

/**
 * Prints the input field for the banner note.
 */
function lw_hello_banner_field() {
	printf(
		'<input type="text" class="regular-text" id="lw_hello_banner_note" name="lw_hello_banner_note" value="%s">',
		esc_attr( get_option( 'lw_hello_banner_note', '' ) )
	);
}

/**
 * Loads the bundled script on the front page only.
 */
function lw_hello_banner_assets() {
	if ( ! is_front_page() ) {
		return;
	}

	wp_enqueue_script(
		'lw-hello-banner',
		plugins_url( 'assets/lw-hello-banner.js', __FILE__ ),
		array(),
		LW_HELLO_BANNER_VERSION,
		array(
			'in_footer' => true,
			'strategy'  => 'defer',
		)
	);
}
add_action( 'wp_enqueue_scripts', 'lw_hello_banner_assets' );

/**
 * Prints the banner on the front page.
 */
function lw_hello_banner_render() {
	if ( ! is_front_page() ) {
		return;
	}

	// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display value, nothing is stored.
	$name = isset( $_GET['lw_name'] ) ? sanitize_text_field( wp_unslash( $_GET['lw_name'] ) ) : '';
	if ( '' === $name ) {
		$name = __( 'Guest', 'lw-hello-banner' );
	}

	$greeting = sprintf(
		/* translators: %s: visitor name */
		__( 'Hello, %s!', 'lw-hello-banner' ),
		$name
	);

	$note = get_option( 'lw_hello_banner_note', '' );

	echo '<div class="lw-hello-banner"><p>' . esc_html( $greeting );
	if ( '' !== $note ) {
		echo ' ' . esc_html( $note );
	}
	echo '</p><button type="button">' . esc_html__( 'Dismiss', 'lw-hello-banner' ) . '</button></div>';
}
add_action( 'wp_body_open', 'lw_hello_banner_render' );

The custom URL parameter is lw_name. WordPress already uses the generic name parameter to select a post, so that parameter can change the requested page or produce a 404 response. The example value is cleaned and displayed only as escaped text. The narrowly scoped phpcs:ignore comment therefore suppresses the nonce warning on that line. That reasoning would be wrong for a save operation, which also needs form and permission checks. The article on WordPress security functions explains the distinction.

Text
=== LW Hello Banner ===
Contributors: lukaswojcik
Tags: banner, greeting
Requires at least: 6.3
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 0.2.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Shows a short, dismissible greeting banner at the top of the front page.

== Description ==

LW Hello Banner prints a greeting on the front page. An optional note can be set under Settings > General.

== Changelog ==

= 0.2.0 =
* Add output escaping, setting sanitization and a local script.

= 0.1.0 =
* Initial version.

Tested up to states the WordPress major version actually tested, such as 7.1. Changing the number solely to silence a finding is not a test. The rules can report an outdated value as an error for new plugins and a warning in update mode. A patch value such as 7.1.2 can also be flagged. Licence declarations in the readme and main file must agree. The readme and plugin-header article covers the fields.

JavaScript
// assets/lw-hello-banner.js
( function () {
	const banner = document.querySelector( '.lw-hello-banner' );
	if ( ! banner ) {
		return;
	}

	const button = banner.querySelector( 'button' );
	if ( button ) {
		button.addEventListener( 'click', function () {
			banner.hidden = true;
		} );
	}
} )();

Plugin Check in GitHub Actions

The official action comes from WordPress/plugin-check-action. This workflow was traced through the v1.1.9 source. The action sets up Node 24 and the local WordPress test environment wp-env, installs Plugin Check and dependencies, and activates the examined plugin. Its command uses --require for runtime checks. The usual wp-env setup needs Docker; the workflow uses a GitHub-hosted Ubuntu test machine.

The action reads the result file and adds findings beside the affected files. A processed ERROR sets exit code 1; a warning alone does not. With strict: true, processed warnings also fail the job. This only covers result entries actually recognized by the parser. Incomplete output or disabled checks cannot establish general security. Pull requests can receive a summary comment, and the raw result file is saved as a downloadable artifact.

Inputs in v1.1.9: build-dir (default ./), checks, exclude-checks, categories, exclude-files, exclude-directories, ignore-codes, ignore-warnings, ignore-errors, include-experimental, wp-version (latest or trunk), severity, error-severity, warning-severity, include-low-severity-errors, include-low-severity-warnings, slug, strict and repo-token. List inputs take one entry per line.

build-dir selects the examined folder. Its last path component normally becomes the slug; the slug input can override it. A different name can produce incorrect translation findings. Development material such as .github also does not belong in the shipped plugin. The workflow therefore builds a separate output folder using git archive. Entries marked export-ignore in .gitattributes stay out. Only files already in the Git commit enter this archive.

YAML
# .github/workflows/plugin-check.yml
name: Plugin Check

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read
  pull-requests: write # only for the summary comment on pull requests

jobs:
  plugin-check:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      # Build the folder that would be shipped, without .github and other
      # development files (see export-ignore in .gitattributes).
      - name: Build distributable plugin folder
        run: |
          mkdir -p build
          git archive --format=tar --prefix=lw-hello-banner/ HEAD | tar -x -C build

      - name: Run Plugin Check
        uses: wordpress/plugin-check-action@v1.1.9
        with:
          # The folder name becomes the slug (text domain check, readme check).
          build-dir: ./build/lw-hello-banner
          wp-version: latest
          # Optional filters, one entry per line:
          # categories: |
          #   plugin_repo
          #   security
          # exclude-checks: |
          #   enqueued_scripts_scope
          # ignore-codes: |
          #   textdomain_mismatch
          # Fail on warnings as well:
          # strict: true
Text
# .gitattributes: files that git archive leaves out of the build
/.gitattributes export-ignore
/.github        export-ignore
/.gitignore     export-ignore
/build          export-ignore

pull-requests: write permits the optional summary comment. Pull requests from external forks may have restricted permissions. The action logs a comment failure as an error annotation but does not additionally set the exit code there. The workflow references v1.1.9 instead of the moving v1 tag. A fully reviewed commit hash fixes code identity more strongly; version tags can also be moved.

Limits and open points

  • Plugin Check does not replace manual review. A clean result provides no approval guarantee.
  • The PHP examples passed syntax checks. Isolated tests checked the archive layout, banner button and official result parser. No complete WordPress run or GitHub-hosted workflow was performed for these examples here.
  • Some checks infer likely naming patterns from the code. A consistent function prefix such as lw_hello_banner_ reduces collisions, but an inferred rule still needs interpretation.
  • Action v1.1.9 has no input that fixes the Plugin Check version. On a fresh environment, it installs the directory version. Fixing the action tag alone therefore does not fix every dependency.
  • There is no PHP-version input. In v1.1.9 only trunk selects a different WordPress source; other wp-version values use the current release. Older-version tests need a separate setup, discussed in a separate article in this series.
  • Script findings depend on the pages and loading conditions examined. The article on selective script removal explains selective removal of plugin scripts.

Questions and answers

Does a run without findings guarantee that the plugin is accepted into the directory?

No. A clean run helps prepare a submission but does not guarantee approval. Manual review addresses further questions such as service purpose, presentation and licensing.

Why does the GitHub Action report more than a local wp plugin check run?

Three differences deserve an initial check:

  • The action loads cli.php and activates the examined plugin. A local run may omit runtime checks.
  • The Plugin Check versions or selected checks may differ.
  • The build folder may include development files that are absent from the release.

When is it acceptable to suppress a warning?

Only a demonstrably false finding should be suppressed. A harmless text display may justify a phpcs:ignore comment on that exact line, with a reason. This is not a general exception for URL input. ignore-codes hides the same code throughout the plugin and can conceal real errors.

Why is the same text domain reported twice?

The header check examines the file header; the i18n check examines individual translation calls. Both must refer to the same plugin. An incorrect slug can therefore create many findings. The action input slug or CLI flag --slug explicitly sets the comparison value.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in touch

A question or an idea for a web project? Tell me a little about it. This form uses reCAPTCHA to protect against spam.

Write a comment

Experience with other plugins or hosting environments and questions about the setup are welcome here.

The email address is not published. Required fields are marked with an asterisk.