wp-cli/eval-command
Run arbitrary PHP code or files from the command line with WP-CLI. wp eval executes a code string, while wp eval-file loads a PHP file with positional arguments. Both support --skip-wordpress to run without WordPress and --hook to run after a specific WordPress hook fires.
First, calibrate expectations. This is not a Composer package you require into a Laravel app. It is a WP-CLI bundled command that ships with WP-CLI itself, so there is nothing to install on a normal machine — if wp --info works, wp eval already exists. The Laravel-developer value proposition is narrow but real: whenever a Laravel codebase touches WordPress (headless WP as a content API, a legacy WP install being migrated into Laravel, a corcel-backed read layer, or a WP site deployed from the same pipeline as a Laravel app), wp eval is the fastest way to inspect and mutate WordPress state from the shell — the closest thing WordPress has to php artisan tinker.
Minimal verification steps:
wp --info # confirm WP-CLI + PHP version
wp eval 'echo WP_CONTENT_DIR;' # does it see the WordPress install?
wp eval 'echo get_bloginfo("version");'
wp eval-file - < /dev/null # confirm STDIN path works
Run these from the WordPress root (or pass --path=/var/www/wp). If wp eval 'echo WP_CONTENT_DIR;' prints an empty line, you are outside a WP install; if it errors, you are likely on PHP < 7.2.24 (the floor set in v2.2.7; recent releases require WP-CLI ≥ 2.13).
Where to look first: wp help eval and wp help eval-file are generated from the same docblocks as the README and are always accurate for your installed version. Then read the two flags that determine everything about behaviour — --skip-wordpress and --hook=<hook> — because they define the three execution contexts you will actually use.
Your first use case should be the one that replaces "SSH in, edit a theme file, reload, revert": a one-off data check against the live database.
# Laravel dev's "what's in this table?" moment, WordPress edition
wp eval 'global $wpdb; var_export( $wpdb->get_results( "SELECT option_name FROM {$wpdb->options} WHERE autoload = \"yes\" LIMIT 20" ) );'
# Equivalent of tinker's User::count()
wp eval 'echo count( get_users( [ "role" => "administrator" ] ) );'
Installation note: you only ever run wp package install wp-cli/eval-command to override the bundled version (e.g. to pull in the --hook flag on an older WP-CLI, or to test a branch). Do not add this to a Laravel composer.json; keep WP-CLI in your global toolchain or a project-local vendor/bin via wp-cli/wp-cli-bundle in a separate tooling directory.
bin/*.php as your WordPress "Artisan commands"The single highest-leverage habit: never grow a wp eval one-liner beyond a line or two. Promote it to a versioned file and run it with wp eval-file. This gives you reviewable, testable, re-runnable scripts that live in the repo next to your Laravel app/Console/Commands.
wp eval-file bin/wp/backfill-meta.php
<?php
// bin/wp/backfill-meta.php
// Runs with WordPress fully loaded; $args holds positional CLI arguments.
global $wpdb;
$limit = isset( $args[0] ) ? (int) $args[0] : 100;
$dry = in_array( '--dry-run', $args, true );
$ids = get_posts( [
'post_type' => 'post',
'posts_per_page' => $limit,
'fields' => 'ids',
'meta_key' => '_legacy_flag',
] );
foreach ( $ids as $id ) {
if ( $dry ) {
WP_CLI::log( "would migrate {$id}" );
continue;
}
update_post_meta( $id, '_new_flag', get_post_meta( $id, '_legacy_flag', true ) );
delete_post_meta( $id, '_legacy_flag' );
}
WP_CLI::success( sprintf( '%d posts processed', count( $ids ) ) );
Key details that make this work like an Artisan command:
$args is a positional array (wp eval-file file.php 100 --dry-run → $args[0] === '100'). Since v2.2.7 $args is registered as a global, so functions inside your file still need global $args;.WP_CLI::log(), WP_CLI::success(), WP_CLI::warning(), WP_CLI::error() instead of echo/exit(). WP_CLI::error() sets a non-zero exit code, which is what makes the script usable in CI or in a Laravel Process::run() call.return and exit behave sanely; a throw produces a stack trace and a failure exit code.The most common friction for a Laravel dev is bash quoting colliding with PHP's $ variables, namespaces (\WP_User), and nested quotes. Three escalating escapes:
# Single-quote the PHP, double-quote inside it
wp eval 'echo get_option( "blogname" );'
# Multi-line: use a heredoc into eval-file's STDIN target
wp eval-file - <<'PHP'
<?php
foreach ( get_sites() as $site ) {
WP_CLI::log( $site->blog_id . ' ' . get_blog_option( $site->blog_id, 'blogname' ) );
}
PHP
# Or keep it in a file and use include semantics (see pattern 4)
wp eval-file bin/wp/audit.php --use-include
The - STDIN form combined with a quoted heredoc (<<'PHP') is the pattern to standardise on for anything with $ in it — it also keeps secrets and tokens out of shell history if you read from a file descriptor instead.
--hook / --skip-wordpress triad)| Context | Invocation | When a Laravel dev needs it |
|---|---|---|
| No WordPress at all | --skip-wordpress |
Pure PHP: hashing, JSON reshaping, calling a Laravel/Composer autoloader, checking an extension. Fast — no WP bootstrap. |
WordPress, default (after_wp_load) |
plain wp eval |
Data reads/writes, $wpdb, options, posts, terms, cron. Most work happens here. |
| WordPress, deferred to a hook | --hook=wp_loaded / init / after_setup_theme |
Anything requiring the current user, registered post types/taxonomies from plugins, rewrite rules, or code that must run at a plugin-like moment. |
The --hook flag (added in v2.2.8) is the one Laravel devs under-use. Anything touching wp_get_current_user(), current_user_can(), or a custom post type registered by a plugin will silently misbehave under the default load order:
wp eval 'echo "Current user: " . wp_get_current_user()->user_login;' --hook=wp_loaded
wp eval 'var_dump( post_type_exists( "acme_product" ) );' --hook=init
--use-include for anything with a namespace or declareAdded in v2.2.0, this switches from eval() of the file's contents to include of the file itself. Two things only work this way:
declare( strict_types=1 );namespace Acme\Scripts;…because eval'd code is injected into a method body, where both statements are illegal. It also makes stack traces point at your real file and line numbers, and makes __FILE__/__DIR__ resolve naturally.
wp eval-file bin/wp/sync.php --use-include
Practical rule: eval-file for scripts you require other files from or that use namespaces; plain eval-file for throwaway scripts that just run top-to-bottom.
For hybrid stacks where WordPress and Laravel share a codebase or a database, --skip-wordpress is your escape hatch to boot only what you need — but the genuinely useful direction is the reverse: use wp eval-file as a WordPress-context entry point that then pulls in shared domain code.
<?php
// bin/wp/publish-to-laravel.php — run with: wp eval-file bin/wp/publish-to-laravel.php
// WordPress context: read posts, hand them to a Laravel service, write back an ID.
require_once __DIR__ . '/../../vendor/autoload.php'; // __DIR__ is correct in eval-file
$app = require __DIR__ . '/../../bootstrap/app.php';
$app->make( Illuminate\Contracts\Console\Kernel::class )->bootstrap();
$importer = $app->make( App\Services\ContentImporter::class );
foreach ( get_posts( [ 'post_type' => 'post', 'numberposts' => -1 ] ) as $post ) {
try {
$id = $importer->import( $post );
update_post_meta( $post->ID, '_laravel_id', $id );
} catch ( Throwable $e ) {
WP_CLI::warning( "post {$post->ID}: " . $e->getMessage() );
}
}
WP_CLI::success( 'sync complete' );
This is the pattern that lets you keep one Eloquent model definition and reuse it from cron-less WP runs. Be aware you now have two frameworks' error handlers and two autoloaders in one process — see the gotchas.
Because it is just a CLI process, it composes cleanly with the tooling a Laravel dev already has:
// composer.json (tooling-only section)
"scripts": {
"wp:audit": "wp eval-file bin/wp/audit.php --use-include",
"wp:seed-dev": "wp eval-file bin/wp/seed-dev.php --url=dev.example.test",
"wp:migrate-urls": "wp search-replace 'http://old' 'https://new' --all-tables --dry-run"
}
And in CI, use eval as a smoke test rather than a data mutation:
wp eval 'exit( is_plugin_active( "advanced-custom-fields/acf.php" ) ? 0 : 1 );'
# non-zero exit fails the build
From Laravel itself, Process::run(['wp', 'eval', '...', '--path=/srv/wp']) gives you a typed, testable integration — with the caveat that you are invoking a process, so pass arguments as an array (never shell-concatenated strings) to avoid quoting and injection problems.
wp eval for reads, real commands for writesThe mature position: wp eval is a diagnostic and one-off tool, not a deployment mechanism. If a snippet becomes something you run on every deploy, promote it one of two ways:
bin/wp/*.php script invoked from your deploy script (fast, cheap, zero packaging), orwp scaffold package) that exposes wp acme sync with proper argument parsing, --dry-run, and Behat coverage.The second is what the WP-CLI maintainers expect; the eval command is explicitly framed as the escape hatch, not the architecture. Extension point worth knowing: --hook defers execution to an arbitrary hook name, so you can piggyback on your own custom action (--hook=acme_ready) and treat that as an extension seam without shipping a package.
wp eval respects --url and --path, which is how you scope to one site in a network:
wp eval 'echo get_option( "blogname" );' --url=one.example.com
wp eval-file bin/wp/each-site.php --url=one.example.com # inside: switch_to_blog() explicitly
Always pass --url explicitly in scripts. Relying on the default site makes a script that works locally destructive in a network context.
Both README sections carry the same warning: code is executed within a method, so globals must be explicitly globalized. Concretely:
# Wrong — $wpdb is a local, null. Fatal error, not a silent empty result.
wp eval 'var_dump( $wpdb->prefix );'
# Right
wp eval 'global $wpdb; var_dump( $wpdb->prefix );'
Same for $post, $wp_query, $current_user, and — since v2.2.7 — $args, which is registered as a global specifically so files can global $args;. Inside a function in your eval'd file, you must globalize it again.
echo only — return values are discardedwp eval 'return 1 + 1;' prints nothing and exits 0. This differs from tinker and trips people up constantly. Use echo, var_export(), var_dump(), print_r(), or WP_CLI::log(). For structured output, WP_CLI\Utils\format_items( 'table', $rows, $fields ) gives you readable tables for free.
__FILE__ / __DIR__ are rewritten, but only in eval modeWP-CLI replaces path magic constants before eval() so that require __DIR__ . '/x.php' works inside an eval'd file (fixed in v2.0.3/v2.0.6, consolidated onto Utils\replace_path_consts() in v2.1.0). Two consequences:
__FILE__ inside larger literals can behave unexpectedly.--use-include sidesteps the whole mechanism. If a script does heavy path work or requires siblings, use --use-include.Related: v2.0.2 strips a leading #! /usr/bin/env php shebang from files, so your scripts can be directly executable and wp eval-file-able. Keep the shebang; don't remove it to work around something.
$assoc_args works in wp eval (since v2.2.9) — use it for parameterswp eval 'WP_CLI::log( $assoc_args["env"] ?? "none" );' --env=staging
This is undocumented in the README body and easy to miss. It gives you a lightweight way to parameterise one-liners without switching to a file. For eval-file, prefer positional $args — it is explicitly supported and stable.
An eval'd snippet that fatals takes the whole WP-CLI process with it; there is no try/catch if the failure is a PHP fatal (type error, calling a method on null). Mitigations:
try { … } catch ( \Throwable $e ) { WP_CLI::warning( $e->getMessage() ); } — this catches \Error too.ob_start().--skip-wordpress "for speed" — you lose $wpdb and every WP function, and the failure mode is Call to undefined function, which reads like a broken install.Double-quoted bash strings interpolate $wpdb, $args, and $post before PHP sees them. The symptom is a snippet that works in a PHP file and produces empty output on the CLI. Rules of thumb:
" inside PHP over ' to avoid nested-quote gymnastics.wp eval-file - heredoc form from the patterns section.eval a string built from a shell variable or user input — see the security note below.sudo-class toolwp eval is unauthenticated arbitrary code execution with the privileges of the WordPress install — full DB access, ability to shell_exec, ability to write files. Keep it to local/dev/staging and deliberate, reviewed production use. Concretely:
--eval string. If you must accept input, pass it via $args and treat it as untrusted (esc_sql, absint, etc.).getenv()) or from a file via eval-file, never inline.--skip-wordpress does not make it safe; it makes it marginally less dangerous.If you require a Laravel bootstrap inside wp eval-file (pattern 5), expect:
vendor/autoload.php files may both claim the same class. Check composer.json autoload maps before assuming the WP one wins.HandleExceptions bootstrap installs an error handler that will convert warnings to ErrorException, including WordPress deprecations. Wrap plugin-heavy calls in error_reporting() scoping or run the Laravel bootstrap after WordPress work, not before.app() will be a singleton for the whole process; a loop over 5,000 posts sharing one container is fine for DB connections but will leak memory in Eloquent's relation cache. Chunk and unset() or use DB::table() for bulk work.--skip-wordpress + Laravel is usually the wrong tool. If you don't need WordPress, use artisan tinker — you get proper REPL ergonomics, PsySH, and no quoting pain.--hook and $assoc_args may be absent.--url; never assume the default site.wp eval-file script.php rather than inline wp eval in any cross-platform script.WP_CLI\Utils\make_progress_bar() and wp_cache_flush()/unset() inside loops, or you will OOM on a large migration and leave data half-written.When a snippet "does nothing":
echo? (return values are dropped)global $wpdb; / global $args;?$variables? (switch to heredoc)--hook=wp_loaded or --hook=init)--url)--path)try/catch you forgot about? (temporarily var_dump( $e ))wp eval is a general tool with specific neighbours that do the job better:
| Task | Better command |
|---|---|
| Search/replace across tables | wp search-replace --dry-run then without |
| Cron inspection | wp cron event list / wp cron event run <hook> |
| Option/transient reads | wp option get, wp transient get |
| DB queries with clean output | wp db query |
| Bulk post meta edits | your own wp eval-file script, or wp post meta update in a loop |
| Interactive inspection | wp shell (a REPL over the same bootstrap) |
Learn the boundaries: reach for eval when no first-class command exists, when you need to compose several APIs in one transaction-ish unit, or when you are deliberately probing WP-CLI's own behaviour. Reach for a real command the moment the snippet acquires if ( $args[0] === '--force' ).
How can I help you explore Laravel packages today?