php-stubs/wp-cli-stubs
PHP stub definitions for WP-CLI, providing classes, functions, and constants for IDE autocompletion and static analysis. Helps PHPStan, Psalm, and editors understand WP-CLI APIs without running the command-line tool.
php-stubs/wp-cli-stubs is a declaration-only package: it ships .php files that define WP_CLI, WP_CLI_Command, WP_CLI\Utils\*, WP_CLI\Iterators\*, WP_CLI\Dispatcher\* and the other globals WP-CLI exposes without any bodies. It exists so PHPStan, Psalm, and PhpStorm can resolve WP-CLI symbols in projects that never load the WP-CLI runtime themselves (plugins, themes, WP-CLI packages, test suites). It changes nothing at runtime — if you need behaviour, install wp-cli/wp-cli or use the wp binary.
1. Install as a dev dependency
composer require --dev php-stubs/wp-cli-stubs
It belongs in require-dev. It is never a production dependency.
2. Look at what you got first
ls vendor/php-stubs/wp-cli-stubs/
# typically: wp-cli-stubs.php composer.json README.md LICENSE
Open the root stub file and skim it — that is authoritative. It tells you exactly which methods exist on WP_CLI and which WP_CLI\Utils\* functions are declared in this version. If the file name differs, use whatever the package README shows.
3. Wire it into PHPStan
# phpstan.neon
parameters:
level: 6
paths:
- src
- tests
scanFiles:
- vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php
scanFiles is the right key here: the symbols are simply "made known" to PHPStan. (Use stubFiles only when you need to override a definition PHPStan already sees — see Gotchas.)
4. First use case: a WP-CLI command in a plugin
Without the stubs, this file produces "Class WP_CLI_Command not found" and "Call to undefined method WP_CLI::success()":
<?php
declare(strict_types=1);
namespace Acme\Plugin\Cli;
use WP_CLI;
use WP_CLI_Command;
use WP_CLI\Utils;
final class Cache_Command extends WP_CLI_Command
{
/**
* Flush the Acme cache.
*
* ## OPTIONS
*
* [--store=<store>]
* : Cache store to flush.
*
* ## EXAMPLES
*
* wp acme cache flush --store=object
*
* @param array<int, string> $args
* @param array<string, string> $assoc_args
*/
public function flush( array $args, array $assoc_args ): void
{
$store = Utils\get_flag_value( $assoc_args, 'store', 'object' );
// ... actual work ...
WP_CLI::success( "Flushed {$store}." );
}
}
Registration, guarded so the plugin still loads under normal WordPress:
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'acme cache', \Acme\Plugin\Cli\Cache_Command::class );
}
Re-run PHPStan and the three errors — undefined class, undefined method, undefined function — disappear. That is the entire value proposition: you get static analysis on the WP-CLI surface area of your own code.
Stack the stub packages, don't pick one
Most projects that use this also need WordPress core symbols. The idiomatic combination is:
composer require --dev \
szepeviktor/phpstan-wordpress \
php-stubs/wp-cli-stubs
szepeviktor/phpstan-wordpress brings php-stubs/wordpress-stubs and a .neon extension for core; it does not cover WP-CLI. Add wp-cli-stubs to scanFiles yourself and you get full coverage of WP_User, $wpdb, WP_CLI, and WP_CLI\Utils\* in one analysis run.
Keep the command class thin, test the logic elsewhere
WP_CLI is entirely static — it is not injectable and PHPUnit cannot meaningfully mock it. The pattern that survives both static analysis and testing is:
final class Cache_Command extends WP_CLI_Command
{
public function __construct( private Cache_Flusher $flusher ) {}
public function flush( array $args, array $assoc_args ): void
{
$store = Utils\get_flag_value( $assoc_args, 'store', 'object' );
$this->flusher->flush( $store );
WP_CLI::success( 'Flushed ' . $store . '.' );
}
}
Now unit tests exercise Cache_Flusher with zero WP-CLI involvement, and integration tests exercise the command via the real binary:
$out = WP_CLI::runcommand( 'acme cache flush --store=object', [ 'return' => true ] );
Both forms — the class and runcommand — type-check because the stubs declare them.
Use the WP_CLI\Utils namespace explicitly
Everything useful lives under WP_CLI\Utils\: get_flag_value(), format_items(), report_batch_operation_results(), launch_editor_for_input(), http_request(), get_sql_identifier(). Import the namespace and call Utils\foo(...) rather than aliasing individual functions — it stays readable and matches how WP-CLI's own docs read.
Override never returns with a local stub
Real WP_CLI::error() and WP_CLI::halt() terminate the process, but older stub revisions may type them as void. If PHPStan then misses unreachable code or asks for a return after an error branch, add a small local stub and point stubFiles at it:
parameters:
stubFiles:
- stubs/wp-cli-overrides.stubphp
<?php
namespace WP_CLI {
class_alias( \WP_CLI::class, \WP_CLI::class ); // no-op guard
}
In practice the cleaner route is a one-line docblock on your own wrapper, or simply throw in the branch PHPStan complains about — do not try to "fix" the vendor stub in place.
Psalm
<stubs>
<file name="vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php"/>
</stubs>
Psalm's <stubs> behaves like PHPStan's scanFiles for this purpose.
CI
- run: composer install --no-interaction
- run: vendor/bin/phpstan analyse --no-progress
No extra step. The stubs are just files on disk.
IDE
PhpStorm indexes vendor/ by default, so composer require --dev alone gives you completion inside WP_CLI_Command subclasses. If your IDE skips non-autoloaded files, add the stub file as an Include Path (Settings → PHP → Include Path) rather than adding it to Composer's autoload map.
Stubs are declarations only — never let them load at runtime. If the stub file is ever required inside a real WP-CLI process, PHP fatals on "Cannot declare class WP_CLI, because the name is already in use." Keep the package in require-dev, do not add it to autoload.files, and do not copy stub files into a plugin's includes.
scanFiles vs stubFiles is the most common confusion. scanFiles adds unknown symbols; stubFiles replaces known ones. If PHPStan says "already defined" — usually because you also installed wp-cli/wp-cli or pulled the same file in twice via a glob — move the path from scanFiles to stubFiles, or drop the duplicate.
Never glob. scanFiles: vendor/php-stubs/*/*.php silently drags in WooCommerce, WordPress, and anything else installed. List each stub file explicitly.
Version skew is real. The stubs are generated from a specific WP-CLI release. If you use a WP_CLI\Utils\* function added in a recent WP-CLI but pin an old stub package, PHPStan reports it as undefined. Bump php-stubs/wp-cli-stubs when you bump WP-CLI; conversely, when generated stubs lag a WP-CLI release, write the call behind your own small wrapper and analyse the wrapper.
WP_CLI_Command is a thin base class. The stubs declare it but obviously cannot model magic dispatch. Everything the wp binary does — routing wp acme cache flush to flush(), parsing ## OPTIONS, hydrating $assoc_args — is invisible to static analysis. That means: PHPStan will never catch a typo in your ## OPTIONS block, and it will not know that $assoc_args['store'] exists. Keep the docblock accurate by hand and type $args/$assoc_args yourself.
The WP_CLI constant. Real WP-CLI defines define( 'WP_CLI', true ); the stubs may or may not. If PHPStan flags WP_CLI as an undefined constant inside your if ( defined( 'WP_CLI' ) && WP_CLI ) guard, add a bootstrapFiles entry:
<?php // stubs/bootstrap.php
define( 'WP_CLI', true );
Do not define it in shipped plugin code.
If you already have wp-cli/wp-cli installed (common for wp package development or phar builds), the real classes are in vendor/. Adding the stubs on top is harmless for analysis but redundant — and occasionally produces duplicate-symbol warnings. Check composer show wp-cli/wp-cli before adding this package.
No generics, no array shapes. Utils\format_items() takes an untyped array and echoes; Utils\get_flag_value() returns mixed. In a strict project you will end up casting. Wrap them once:
/** @param array<string, string> $assoc_args */
function acme_flag( array $assoc_args, string $key, string $default = '' ): string {
return (string) \WP_CLI\Utils\get_flag_value( $assoc_args, $key, $default );
}
"0 dependents" is misleading. Stub packages are almost always require-dev, and dev dependencies are excluded from Packagist's dependent counts. Read "0 dependents" as "not counted", not "unused".
WP_CLI::add_hook() / do_hook() are typed loosely. Hooks are the extension point for WP-CLI internals; the stub signatures are string + callable, so PHPStan cannot check your callback's arity. Annotate the callback explicitly or accept the gap.
Extending: if you maintain multiple packages needing the same setup, publish a tiny internal phpstan-extension package with an extension.neon that scanFiles both the WordPress and WP-CLI stubs, and register it via phpstan/extension-installer. That is exactly what szepeviktor/phpstan-wordpress does for core; there is no equivalent wrapper for WP-CLI yet, which is the obvious gap in this package's ecosystem.
How can I help you explore Laravel packages today?