WP-CLI
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
WP-CLI is the command-line interface for WordPress: wp plugin update --all, wp search-replace, wp db export
and hundreds of other commands do from a shell what the dashboard does with clicks, and more. Administrators use it to
install, update, back up and migrate sites; developers use it to inspect and seed data and to give plugins commands of
their own. This page covers installing and configuring it, remote sites, the commands other pages already use (linked,
not repeated), packages, and the custom wp lumen import command of the lumen-catalog plugin.
The page was written against WP-CLI 2.12.0 (released 2025-05-07; wp cli check-update still answered "WP-CLI is at
the latest version"), WordPress 7.1.3 (2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in
production), PHP 7.4 minimum and 8.3+ recommended, and WPCS 3.4.1, and checked on 2026-10-28. WordPress 7.2 is in beta,
planned for 8 December 2026. WP-CLI’s main branch is
3.0 in development: it raises the PHP requirement from 5.6 (2.12) to 7.2.24, and the online handbook already describes
some 3.x features, flagged below where they matter. Every output comes from containers built from
wordpress:cli-2.12.0-php8.4, wordpress:7.1.3-php8.4-apache and mariadb:11.8, a local site at
https://lumenpress.test and a staging site reached over SSH.
How WP-CLI runs WordPress
wp is a PHP Phar that boots WordPress like a web request (wp-config.php, then wp-settings.php) but without HTTP:
no $_SERVER['HTTP_HOST'] (--url supplies one) and no logged-in user (--user sets one). Plugins see the
constant WP_CLI defined as true. Consequences:
-
It runs as the shell user. Files it writes (plugins, uploads,
.htaccess) belong to whoever ranwp, not to the web server user; see File Permissions and Filesystem. -
Capabilities are not checked by WP-CLI itself. Anyone with shell access to the files has full control of the site, so
--userdecides authorship and whatcurrent_user_can()returns inside plugin code, not whether a command is allowed. Runningwpas root is refused unless--allow-rootis passed. -
Context.
--context(2.6+) loads WordPress ascli,admin(for code behindis_admin()) orauto, the default since 2.7:adminforwp pluginandwp themecommands,clifor the rest. -
Commands are PHP classes in separate packages (
wp-cli/core-commandand others, shipped aswp-cli/wp-cli-bundle); plugins and packages add more with the same API.
Installing
The Phar
The recommended installation is one file on the PATH. Pinning the release and checking its SHA-512 sum makes the
install reproducible; the handbook also documents GPG signatures.
curl -LO https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar
curl -LO https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar.sha512
echo "$(cat wp-cli-2.12.0.phar.sha512) wp-cli-2.12.0.phar" | sha512sum -c -
php wp-cli-2.12.0.phar --info
chmod +x wp-cli-2.12.0.phar
sudo mv wp-cli-2.12.0.phar /usr/local/bin/wp
wp --version
Source: WP-CLI Handbook: Installing
In a php:8.4-cli container this printed wp-cli-2.12.0.phar: OK and WP-CLI 2.12.0; wp --info shows the PHP
binary, config files and package folder in use. The handbook’s unversioned URL (.../gh-pages/phar/wp-cli.phar)
always serves the newest build, fine on a workstation but not in provisioning. wp cli update updates a Phar install;
Composer (wp-cli/wp-cli-bundle), Homebrew and Debian packages are updated by their own tools.
The Docker image
The wordpress Docker Official Image has cli variants (wordpress:cli-2.12.0-php8.4: Alpine, PHP, the Phar). The
cli service of Local
Development Environments shares the WordPress volume and settings, so commands run as one-off containers; paths are
container paths, so a host file is mounted first:
docker compose run --rm cli wp plugin list
docker compose run --rm -v "$PWD/data:/data:ro" cli wp lumen import /data/books.csv --user=lena.catalogue
The image runs as Alpine’s www-data (UID 82) while the Debian apache variants use UID 33, so the image
documentation recommends --user 33:33 and HOME=/tmp against a Debian-based install, as the Compose service does.
With wp-env the equivalent is wp-env run cli wp ....
Configuration files
Every global parameter, and the default arguments of any subcommand, can live in YAML. WP-CLI reads, from highest to
lowest priority: the command line, a project file found in the current directory or the nearest parent
(wp-cli.local.yml, else wp-cli.yml), the global ~/.wp-cli/config.yml (WP_CLI_CONFIG_PATH moves it), and its
defaults. The upward search stops when it would leave one WordPress install for an enclosing one.
The Lumen Press repository keeps WordPress in a wordpress/ folder and commits this file at its root:
# Lumen Press: shared WP-CLI configuration, committed to the repository.
path: wordpress
url: https://lumenpress.test
apache_modules:
- mod_rewrite
disabled_commands:
- db drop
- db reset
# Defaults for one subcommand.
post list:
fields: ID,post_title,post_status
@staging:
ssh: deploy@staging.lumenpress.example/srv/www/staging.lumenpress.example
url: https://staging.lumenpress.example
@production:
ssh: deploy@lumenpress.example:2222/srv/www/lumenpress.example
url: https://lumenpress.example
@remote:
- @staging
- @production
Source: WP-CLI Handbook: Config
Each developer may add a git-ignored wp-cli.local.yml. In 2.12 the two files are not merged:
Runner::get_project_config_path() returns the first of wp-cli.local.yml and wp-cli.yml that exists, so a local
file alone drops the shared one (wp cli info listed only the local file, and every command failed with "This does
not seem to be a WordPress installation" because path was gone). The _ key fixes it:
# Per-developer overrides, ignored by Git. Without "inherit", wp-cli.yml is not read at all.
_:
inherit: wp-cli.yml
user: maria.editor
From the repository root wp option get home printed https://lumenpress.test, the current user was
maria.editor (or admin with --user=admin), wp post list showed only the three configured fields, and
wp db drop --yes stopped with "The 'db drop' command has been disabled from the config file."
| Key | Effect |
|---|---|
|
Defaults for the global parameters below; |
|
PHP files loaded before every command (a list); the place for personal helper commands |
|
Apache modules WP-CLI reports as loaded. With |
|
Commands refused with an error; a guard against accidents, not a security boundary (the same user can edit the file) |
|
Default arguments for one subcommand; a named site or a group of aliases |
|
Read another YAML file first, and merge subcommand defaults with the parent file instead of replacing them |
A shared file should not set user: admin, which silently runs every command as an administrator; commands that need
one (global styles changes) pass --user=admin.
Global parameters
Global parameters work with every command, before or after it. 2.12 has no short forms: wp config create -q failed
with "Too many positional arguments: -q", where --quiet works.
| Parameter | Use |
|---|---|
|
WordPress root, when not running from it |
|
The site URL; selects the site on Multisite and sets |
|
Current user, for authorship and capability-dependent code |
|
Run the command on another machine or container (next section) |
|
Do not load all, or the listed, plugins or themes (must-use plugins still load), or WP-CLI packages; the rescue path when one of them fatals |
|
Load a file, or run PHP, before the command |
|
|
|
Output control ( |
--skip-plugins also removes plugin commands: wp --skip-plugins lumen import answered "'lumen' is not a
registered wp command". Environment variables: WP_CLI_PACKAGES_DIR and WP_CLI_CACHE_DIR move folders,
WP_CLI_STRICT_ARGS_MODE=1 makes arguments after the command local (wp option get home --user=admin then fails
with "unknown --user parameter"), and WP_CLI_SSH_PRE_CMD runs a command on the remote side first.
Aliases and remote commands
An alias names a site; wp @staging <command> runs the command there. For an SSH alias, WP-CLI starts ssh, changes
to the alias path and runs the remote wp, which must be installed on the server and on the non-interactive shell’s
PATH (~/bin usually is; ~/.bashrc is not read). Keys come from the agent or ~/.ssh/config; an alias
may also set key (passed as ssh -i) and proxyjump (passed as ssh -J). Besides SSH, the ssh value accepts the
schemes docker:<container>, docker-compose:<service>, docker-compose-run:<service> and vagrant.
$ scp books.csv deploy@staging.lumenpress.example:/tmp/books.csv
$ wp @staging lumen import /tmp/books.csv --user=lena.catalogue
Success: Imported 50 books, skipped 0.
$ wp @staging --debug option get home 2>&1 | grep 'Running SSH'
Debug (bootstrap): Running SSH command: ssh -T -vvv 'deploy@staging.lumenpress.example' 'cd '\''/srv/www/staging.lumenpress.example'\''; WP_CLI_RUNTIME_ALIAS=... wp @staging '\''--debug'\'' '\''option'\'' '\''get'\'' '\''home'\''' (0.03s)
$ wp @remote option get home; echo "exit=$?"
@staging
https://staging.lumenpress.example
@production
Error: Cannot connect over SSH using provided configuration.
exit=0
File arguments are remote paths, so the CSV is copied first; global parameters such as --user travel with the
command. An alias group runs every member in turn and, in 2.12, exited 0 although production failed, so deployment
scripts loop over single aliases and check each exit code instead of trusting @remote (or the implicit @all).
wp cli alias list, get, add, update, delete and is-group manage aliases from the shell.
The most-used commands
Every command accepts --help; most list commands accept --format=table|csv|json|yaml|ids|count, --fields
and --field, and create commands accept --porcelain to print only the new ID, which is how commands are chained:
wp post delete $(wp post list --post_type=lp_book --post_status=trash --format=ids) --force. A command exits 0 on
success and 1 on error, so set -e scripts stop at the first failure. The pages of this documentation already show
each family on a real task; this table points to them.
| Commands | Typical tasks | Shown in |
|---|---|---|
|
|
|
|
|
|
|
|
Choosing and Managing Plugins and Themes, Plugin Fundamentals, Themes Overview |
|
Create users, |
Users, Roles and Profiles, Roles and Capabilities API, Authentication and Login API |
|
Create and list content, seed test data, inspect meta |
|
|
Taxonomy terms, comment moderation |
Categories, Tags and Comments, Custom Post Types and Taxonomies |
|
|
|
|
|
|
|
Change URLs safely in serialized data, |
|
|
|
|
|
|
Options, Transients and Object Cache, Performance and Caching |
|
|
|
|
|
|
|
|
|
|
|
|
|
Create, list and archive sites, network meta |
|
|
WXR export and import (the importer plugin is needed for |
|
|
Classic menus and widget areas |
|
|
|
|
|
Run PHP inside a loaded WordPress: inspection, one-off fixes, debugging |
Hooks: Actions and Filters, Debugging, Site Health and Troubleshooting |
|
PHP’s built-in web server on the current install, for quick checks |
|
|
Version, info, update, aliases, |
This page |
Source: WP-CLI Commands
Packages
Packages are to WP-CLI what plugins are to WordPress: Composer packages of type wp-cli-package that add commands, are
installed per shell user into ~/.wp-cli/packages/ (WP_CLI_PACKAGES_DIR moves it) and load on every wp run until
--skip-packages is passed. They come from the package index, a Git URL, a local folder or a ZIP file, and a version
constraint follows a colon.
$ php -d memory_limit=512M "$(command -v wp)" package install wp-cli/doctor-command
- wp-cli/doctor-command dev-main requires wp-cli/wp-cli ^3.0 -> found wp-cli/wp-cli[2.12.0] but it does not match the constraint.
Error: Package installation failed (Composer return code 2).
$ php -d memory_limit=512M "$(command -v wp)" package install wp-cli/doctor-command:^2.3
Success: Package installed.
$ wp doctor check autoload-options-size constant-wp-debug-falsy core-verify-checksums
name status message
core-verify-checksums success WordPress verifies against its checksums.
autoload-options-size success Autoloaded options size (37.18kb) is less than threshold (900kb).
constant-wp-debug-falsy success Constant 'WP_DEBUG' is defined falsy.
Without a constraint, 2.12 resolved the package to dev-main, whose main branch (like those of the other official
packages) requires the unreleased WP-CLI 3. wp-cli/dist-archive-command 3.2 does too, so
Publishing to the Plugin Directory pins 3.1.0; until 3.0
ships, every install names a 2.12-compatible version. Composer inside the Phar needs more than 128 MB of memory. Some
doctor checks load the front-end template; here a canonical redirect printed an "Incomplete check execution" warning
above the table (cut) without failing a check. A package is arbitrary PHP running as the shell user on every command
and site, so only reviewed, pinned packages belong there. wp package list, update, uninstall and path manage
them; wp scaffold package (from wp-cli/scaffold-package-command) generates a new one.
Writing a custom command: wp lumen import
The Lumen Press catalogue team receives new titles as CSV files. wp lumen import <file> reads a UTF-8 CSV with the
header title,isbn,pages,published_on,genre, validates each row (title, ISBN-13 check digit, page count, date), skips
ISBNs already in the catalogue or earlier in the file, and creates books with lumen_catalog_import_book() of
Hooks: Actions and Filters (so its lumen_catalog_* hooks fire)
before storing lp_published_on and the lp_genre term. It offers --dry-run, --porcelain, --format, a
progress bar and distinct exit codes.
Commands can be functions, closures or classes. In a class, every public method becomes a subcommand, unless the class
has __invoke(), which makes the class itself the command. Registering the class under the two-word name
lumen import creates the wp lumen namespace automatically and leaves room for later subcommands such as
wp lumen sync-stock. The plugin registers it from Plugin::boot() only when WP-CLI is running:
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 'lumen import', Cli\Import_Command::class );
}
The cli_init action is an equivalent hook. A plugin command exists only where the plugin is active; one that must
run before WordPress loads (@when before_wp_load, ignored in plugins) or without the plugin belongs in a package.
The class, autoloaded by Composer from src/Cli/:
<?php
/**
* The wp lumen import command.
*
* @package Lumen\Catalog
*/
namespace Lumen\Catalog\Cli;
use WP_CLI;
use WP_CLI\Utils;
defined( 'ABSPATH' ) || exit;
/**
* Imports books into the catalogue from a CSV file.
*/
final class Import_Command {
// Columns the CSV header must contain, in any order.
const COLUMNS = array( 'title', 'isbn', 'pages', 'published_on', 'genre' );
/**
* Imports books from a CSV file.
*
* Every valid row becomes a published book. A row whose ISBN is already in the
* catalogue, or earlier in the same file, is skipped, so the command can run
* again after the invalid rows have been fixed.
*
* ## OPTIONS
*
* <file>
* : UTF-8 CSV file whose header is title,isbn,pages,published_on,genre.
*
* [--dry-run]
* : Validate every row and report the result without writing anything.
*
* [--porcelain]
* : Print only the IDs of the new books, one per line.
*
* [--format=<format>]
* : Format of the report of rows that were not imported.
* ---
* default: table
* options:
* - table
* - csv
* - json
* - yaml
* ---
*
* ## EXIT STATUS
*
* 0 when every row was imported or skipped, 1 when the file cannot be used,
* 2 when at least one row was invalid or could not be saved.
*
* @param string[] $args Positional arguments: the CSV file.
* @param array $assoc_args Associative arguments and flags.
*/
public function __invoke( $args, $assoc_args ) {
list( $file ) = $args;
$dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false );
$porcelain = (bool) Utils\get_flag_value( $assoc_args, 'porcelain', false );
$format = Utils\get_flag_value( $assoc_args, 'format', 'table' );
$rows = $this->read_csv( $file );
if ( ! $dry_run && 0 === get_current_user_id() ) {
WP_CLI::error( 'Books need an author: add --user=<login>.' );
}
$known = $this->existing_isbns();
$created = array();
$problems = array();
$progress = $porcelain ? null : Utils\make_progress_bar( $dry_run ? 'Checking books' : 'Importing books', count( $rows ) );
wp_defer_term_counting( true );
foreach ( $rows as $line => $row ) {
$error = $this->validate( $row );
$key = self::digits( $row['isbn'] ?? '' );
$report = array(
'line' => $line,
'isbn' => $row['isbn'] ?? '',
'title' => $row['title'] ?? '',
);
if ( '' !== $error ) {
$problems[] = $report + array(
'result' => 'invalid',
'reason' => $error,
);
$key = '';
} elseif ( isset( $known[ $key ] ) ) {
$problems[] = $report + array(
'result' => 'skipped',
'reason' => 'ISBN already in the catalogue',
);
} elseif ( ! $dry_run ) {
$post_id = lumen_catalog_import_book( $row, 'csv' );
if ( is_wp_error( $post_id ) ) {
$problems[] = $report + array(
'result' => 'failed',
'reason' => $post_id->get_error_message(),
);
} else {
update_post_meta( $post_id, 'lp_published_on', $row['published_on'] );
wp_set_object_terms( $post_id, $row['genre'], 'lp_genre' );
$created[] = $post_id;
}
}
if ( '' !== $key ) {
$known[ $key ] = true;
}
if ( $progress ) {
$progress->tick();
}
}
wp_defer_term_counting( false );
if ( $progress ) {
$progress->finish();
}
if ( $porcelain ) {
array_map( array( WP_CLI::class, 'line' ), $created );
} elseif ( $problems ) {
Utils\format_items( $format, $problems, array( 'line', 'isbn', 'title', 'result', 'reason' ) );
}
$bad = count( wp_list_filter( $problems, array( 'result' => 'skipped' ), 'NOT' ) );
if ( $bad > 0 ) {
WP_CLI::error( sprintf( '%d of %d rows could not be imported.', $bad, count( $rows ) ), 2 );
}
if ( ! $porcelain ) {
WP_CLI::success(
sprintf(
$dry_run ? 'Dry run: %d rows would be imported, %d skipped.' : 'Imported %d books, skipped %d.',
count( $rows ) - count( $problems ),
count( $problems )
)
);
}
}
/**
* Reads the CSV file into rows keyed by line number; stops on unusable files.
*
* @param string $file Path to the file.
* @return array<int, array<string, string>> Rows; a malformed row holds only an `error` key.
*/
private function read_csv( $file ) {
if ( ! is_file( $file ) || ! is_readable( $file ) ) {
WP_CLI::error( "Cannot read {$file}." );
}
$csv = new \SplFileObject( $file );
$csv->setFlags( \SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY | \SplFileObject::READ_AHEAD );
$header = null;
$rows = array();
$line = 0;
foreach ( $csv as $fields ) {
++$line;
$fields = array_map( 'trim', $fields );
if ( null === $header ) {
$fields[0] = preg_replace( '/^\xEF\xBB\xBF/', '', $fields[0] ); // UTF-8 byte order mark.
$header = array_map( 'strtolower', $fields );
$missing = array_diff( self::COLUMNS, $header );
if ( $missing ) {
WP_CLI::error( 'Missing columns: ' . implode( ', ', $missing ) . '.' );
}
continue;
}
$rows[ $line ] = count( $fields ) === count( $header )
? array_combine( $header, $fields )
: array( 'error' => sprintf( 'Expected %d fields, found %d', count( $header ), count( $fields ) ) );
}
if ( ! $rows ) {
WP_CLI::error( "{$file} has no data rows." );
}
return $rows;
}
/**
* Checks one row.
*
* @param array<string, string> $row Row from the CSV file.
* @return string Error message, or an empty string when the row is valid.
*/
private function validate( array $row ) {
if ( isset( $row['error'] ) ) {
return $row['error'];
}
if ( '' === $row['title'] ) {
return 'Empty title';
}
$digits = str_split( self::digits( $row['isbn'] ) );
$sum = 0;
foreach ( array_slice( $digits, 0, 12 ) as $i => $digit ) {
$sum += (int) $digit * ( 0 === $i % 2 ? 1 : 3 );
}
if ( 13 !== count( $digits ) || ( 10 - $sum % 10 ) % 10 !== (int) $digits[12] ) {
return 'Invalid ISBN-13';
}
if ( ! ctype_digit( $row['pages'] ) || (int) $row['pages'] < 1 ) {
return 'Pages must be a positive whole number';
}
$date = \DateTime::createFromFormat( '!Y-m-d', $row['published_on'] );
if ( ! $date || $date->format( 'Y-m-d' ) !== $row['published_on'] ) {
return 'published_on must be YYYY-MM-DD';
}
return '';
}
/**
* Strips everything but digits, so 978-1-23456-789-7 and 9781234567897 match.
*
* @param string $isbn ISBN as written.
* @return string Digits only.
*/
private static function digits( $isbn ) {
return preg_replace( '/\D/', '', (string) $isbn );
}
/**
* Loads the ISBNs already in the catalogue, in one query.
*
* @return array<string, true> Digits-only ISBNs as keys.
*/
private function existing_isbns() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one read per run, a cache would be stale.
$isbns = $wpdb->get_col(
$wpdb->prepare(
"SELECT pm.meta_value FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status <> %s",
'lp_isbn',
'lp_book',
'trash'
)
);
return array_fill_keys( array_map( array( self::class, 'digits' ), $isbns ), true );
}
}
Source: WP-CLI Handbook: Commands Cookbook, WP-CLI Handbook: WP_CLI\Utils\make_progress_bar(), WP-CLI Handbook: WP_CLI\Utils\format_items()
The docblock is the synopsis
WP-CLI parses the method’s PHPDoc: the first line is the short description, the rest up to the first @ tag is the
long description shown by wp help, and the argument tokens in it form the synopsis, which WP-CLI validates before
the method runs. The command never sees an unknown flag or a format outside its list: --format=xml failed with
"Invalid value specified for 'format'" and exit code 1. The @param tags WPCS asks for are not shown in the help.
<file> is a required positional argument (<file>... repeats it, [<file>] makes it optional); [--dry-run]
is an optional flag, true when given and false with --no-dry-run; [--format=<format>] is an optional value
whose YAML block between --- lines sets default and options (without brackets it would be required). The
tags @subcommand (a method cannot be called list), @alias and @when (packages only) complete the set.
The handbook also documents short-form argument aliases ([--dry-run|n]) and repeating associative arguments
([--status=<status>...] collected into an array). Neither works in 2.12: the first produced "invalid synopsis part"
and "Too many positional arguments: -n", the second kept only the last value. They belong to the 3.0 line.
Running the command
wp help lumen import prints the docblock as NAME, DESCRIPTION, SYNOPSIS
(wp lumen import <file> [--dry-run] [--porcelain] [--format=<format>]), the long description, OPTIONS and EXIT
STATUS, then the global parameters. The test file books.csv holds 50 books, among them "The Lighthouse Ledger"
(978-1-23456-789-7), "The Glass Orchard" and "The Quiet Lighthouse", checked first and then imported:
$ wp lumen import books.csv
Error: Books need an author: add --user=<login>.
$ wp lumen import books.csv --dry-run
Checking books 100% [=====================================================] 0:00 / 0:00
Success: Dry run: 50 rows would be imported, 0 skipped.
$ wp lumen import books.csv --user=lena.catalogue
Importing books 100% [====================================================] 0:00 / 0:00
Success: Imported 50 books, skipped 0.
All 50 books belong to user 4 (lena.catalogue), and a second run skipped all 50 rows and exited 0. wp term list
lp_genre showed six genres whose counts add up to 50 although counting was deferred during the loop
(wp_defer_term_counting( false ) recounts once at the end). A second file, autumn.csv, mixes one new title with a
duplicate and three broken rows:
$ wp lumen import autumn.csv --dry-run; echo "exit=$?"
+------+-------------------+---------------------+---------+---------------------------------------+
| line | isbn | title | result | reason |
+------+-------------------+---------------------+---------+---------------------------------------+
| 3 | 978-1-23456-101-7 | The Glass Orchard | skipped | ISBN already in the catalogue |
| 4 | 978-1-23456-202-0 | The Northern Weaver | invalid | Invalid ISBN-13 |
| 5 | 978-1-23456-203-8 | The Velvet Quarry | invalid | Pages must be a positive whole number |
| 6 | 978-1-23456-204-5 | The Salt Kestrel | invalid | published_on must be YYYY-MM-DD |
+------+-------------------+---------------------+---------+---------------------------------------+
Error: 3 of 5 rows could not be imported.
exit=2
$ wp lumen import autumn.csv --user=lena.catalogue --porcelain; echo "exit=$?"
362
Error: 3 of 5 rows could not be imported.
exit=2
Source: WP-CLI Handbook: WP_CLI::error()
The valid row of autumn.csv, "The Patient Cartographer", became book 362 even though the run as a whole failed:
the command imports what it can, reports the rest and signals the failure through the exit code. A missing file
("Cannot read missing.csv.") or one without the required columns ("Missing columns: pages, published_on, genre.")
stops before any write with exit code 1.
Conventions the command follows
| Concern | How |
|---|---|
Streams |
Data ( |
Exit codes |
|
|
Only the new IDs, one per line, no progress bar and no success line; the convention of core’s create commands, for |
|
|
Authorship |
|
Long imports need more care: WordPress caches every post it touches for the life of the process, so very large files
are split into separate runs, and a run of minutes belongs in its own scheduled command rather than a WP-Cron event
(see Scaling and High Availability). In containers, php -l
(PHP 8.4 and 7.4) and PHP_CodeSniffer with WPCS 3.4.1 and the ruleset of
Plugin Fundamentals reported nothing; the direct query carries a reasoned
phpcs:ignore, and SplFileObject avoids the WordPress.WP.AlternativeFunctions warnings for fopen().
Calling commands from code: WP_CLI::runcommand()
WP_CLI::runcommand() runs another command, in a child process by default ('launch' => true), which matters:
WP_CLI::error() exits the process it runs in, so only a child process can fail without ending the caller. This script
imports every CSV file of a folder and keeps going past a failed file:
<?php
/**
* Imports every CSV file of a folder, one child process per file.
*
* Usage: wp eval-file import-folder.php imports --user=lena.catalogue
*
* @package Lumen\Catalog
*/
$lumen_catalog_dir = rtrim( $args[0] ?? 'imports', '/' );
$lumen_catalog_failed = 0;
foreach ( glob( $lumen_catalog_dir . '/*.csv' ) as $lumen_catalog_file ) {
$lumen_catalog_run = WP_CLI::runcommand(
'lumen import ' . escapeshellarg( $lumen_catalog_file ) . ' --porcelain',
array(
'return' => 'all', // Object with stdout, stderr and return_code.
'exit_error' => false, // Keep going when one file fails.
)
);
$lumen_catalog_ids = array_filter( explode( "\n", $lumen_catalog_run->stdout ) );
WP_CLI::log( sprintf( '%s: exit %d, %d new books', basename( $lumen_catalog_file ), $lumen_catalog_run->return_code, count( $lumen_catalog_ids ) ) );
if ( 0 !== $lumen_catalog_run->return_code ) {
WP_CLI::warning( trim( $lumen_catalog_run->stderr ) );
++$lumen_catalog_failed;
}
}
if ( $lumen_catalog_failed ) {
WP_CLI::error( "{$lumen_catalog_failed} file(s) had rows that were not imported.", 2 );
}
WP_CLI::success( 'All files imported.' );
$ wp eval-file import-folder.php imports --user=lena.catalogue; echo "exit=$?"
2026-11-lumen-kids.csv: exit 0, 2 new books
2026-12-autumn.csv: exit 2, 0 new books
Warning: Error: 3 of 5 rows could not be imported.
Error: 1 file(s) had rows that were not imported.
exit=2
Source: WP-CLI Commands: wp eval-file
The children inherited --user=lena.catalogue (the Lumen Kids books belong to her). wp eval-file passes the
arguments after the file as $args. 'return' => true returns only standard output, 'parse' => 'json' decodes
it, and 'launch' => false reuses the process, faster but fatal on any error. wp cli has-command "lumen import"
tests through its exit code whether a command exists (0 here, 1 under --skip-plugins).
Testing commands with Behat
WP-CLI tests its commands with Behat scenarios that run real wp processes against a fresh install and check output
and exit codes. wp scaffold package-tests (from wp-cli/scaffold-package-command) generates the harness for a
package (wp-cli/wp-cli-tests, features/, CI configuration). A scenario for the import command could read:
Feature: Import books from a CSV file
Scenario: Broken rows are reported with exit code 2
Given a WP install
And I run `wp plugin activate lumen-catalog`
And a books.csv file:
"""
title,isbn,pages,published_on,genre
"The Lighthouse Ledger",978-1-23456-789-7,182,2022-11-19,Fiction
"The Velvet Quarry",978-1-23456-203-8,many,2026-12-03,Crime
"""
When I try `wp lumen import books.csv --user=admin --porcelain`
Then the return code should be 2
And STDERR should contain:
"""
1 of 2 rows could not be imported.
"""
I try accepts a non-zero exit code where I run fails the step; the steps exist in wp-cli-tests, and the plugin
must first be placed in the test install. The scenario was not run here: the current wp-cli-tests release line
requires WP-CLI 3, and the scaffolding targets packages. Plugin logic is better tested with PHPUnit and the WordPress
test suite (wp scaffold plugin-tests, see Testing).
Security and common pitfalls
| Symptom or risk | Cause and fix |
|---|---|
"This does not seem to be a WordPress installation" |
Wrong directory, or a |
Files the web server cannot write after |
WP-CLI ran as another user (root with |
A domain change broke widgets or options |
SQL |
A script reports success although production failed |
Alias groups exit 0 when a member fails; loop over single aliases |
Secrets in shell history |
|
Corrections to older books and tutorials
WordPress All-in-One For Dummies does not cover WP-CLI. Resetting a lost password by writing an MD5 hash in
phpMyAdmin, common in older guides, still works because WordPress 6.8+ rehashes it with bcrypt at the next login, but
wp user update <user> --user_pass=<password> or wp user reset-password <user> is the supported route. Ultimate
WordPress Handbook covers WP-CLI basics only; configuration files, aliases, packages and custom commands are here.
Older tutorials predate --context (2.6, default auto since 2.7), assume wp-cli.local.yml adds to wp-cli.yml
rather than replacing it, and run wp package install <name> without a version, which now fails for many official
packages. The online handbook tracks the 3.0 branch: its PHP 7.2.24 requirement and argument aliases are not 2.12.