wp-cli/entity-command
WP-CLI commands for managing WordPress comments, menus, options, posts, sites, terms, and users. Create, update, delete, list, and moderate entities from the command line, with support for metadata, custom fields, and block notes.
This package is not something you composer require into a Laravel app — it is a bundle inside WP-CLI. If wp runs against a WordPress install, wp post, wp comment, wp term, wp user, wp option, wp menu, wp site, and wp cap are already available. The 105 stars are misleading: it ships in the default WP-CLI phar, so it is present on virtually every WordPress host you will touch, which makes it much lower-risk than a random 105-star dependency.
Minimal steps:
wp --info
wp help post
wp help post list
wp help <entity> <subcommand> is always the authoritative, version-matched reference.wp post list --format=json # machine-readable payload on STDOUT
wp post list --field=ID # one column, newline-separated
wp post list --format=ids # space-separated, xargs-friendly
wp post create ./content.html --post_type=page --porcelain # just the new ID
First use case for a Laravel developer: inspecting and mutating WordPress content while you are building the Laravel side of a headless/adjacent install. Typical first command is an export you can feed into an importer:
wp post list --post_type=post --post_status=publish \
--fields=ID,post_title,post_name,post_date,post_content \
--format=json > posts.json
Then json_decode(file_get_contents('posts.json'), true) in a Laravel command. No SQL, no plugin, no auth layer.
JSON in, JSON out, exit code checked. Treat every wp call as an external HTTP-ish dependency. From Laravel, use Symfony\Component\Process\Process with an argument array (never string interpolation — post content with quotes will bite you):
$process = new Process([
'wp', 'post', 'create', './draft.html',
'--post_type=page',
'--post_status=draft',
'--porcelain',
'--path=/var/www/wordpress',
]);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException($process->getErrorOutput());
}
$postId = (int) trim($process->getOutput());
Note that --path (and --url on multisite) is usually how you point wp at a site from a Laravel process whose working directory is unrelated.
Seeder / bootstrap scripts for a fresh site. The * generate commands plus --porcelain compose into idempotent-ish fixture setup:
wp user create editor [email protected] --role=editor --user_pass="$PW" --porcelain
wp post create ./home.html --post_type=page --post_title=Home --post_status=publish --porcelain
wp menu create "Primary"
wp menu item add-post primary 12 --title="Home"
wp menu location assign primary primary
wp option update blogdescription "Staging copy"
wp option update show_on_front page
wp option update page_on_front 12
Bulk operations via --format=ids + xargs. This is the canonical pattern and appears all over the package examples:
# Trash every spam comment
wp comment delete $(wp comment list --status=spam --format=ids)
# Add a meta flag to each generated comment
wp comment generate --format=ids --count=3 --post_id=123 \
| xargs -d ' ' -I % wp comment meta add % reviewed 1
Prefer xargs -d ' ' -I % (or -n1) over bare xargs so IDs are not re-split oddly and so one failure does not silently shift the rest.
Read-modify-write on structured options. wp option + --format=json is the safe way to touch array-valued options without hand-rolling serialization:
wp option get my_plugin_settings --format=json \
| jq '.features.enabled = true' \
| wp option update my_plugin_settings --format=json
Same applies to meta: wp post meta update <id> <key> --format=json stores a properly serialized array rather than the literal string ["a","b"].
Content from files and STDIN. wp post create <file> takes the post body from a file, and meta/option commands read the value from STDIN when the value argument is omitted. That is how you avoid shell-quoting hell for HTML and JSON payloads:
wp post create ./body.html --post_title="Release notes" --porcelain
cat settings.json | wp option update my_settings --format=json
Deploy-time reconciliation. On deploy, run a small script that uses exists/get as a probe, then create-or-update. wp comment exists <id> prints success and exits 0/1; for other entities, wp post get <id> --field=ID with exit-code checking is the equivalent:
if ! wp post get "$ID" --field=ID >/dev/null 2>&1; then
wp post create ./page.html --import_id="$ID" --porcelain
fi
Multisite as a first-class concern. wp site list --fields=blog_id,url,archived,deleted --format=json gives you the network inventory; wp site create --slug=demo --title=Demo and wp site empty <id> --yes handle tenant provisioning and reset. Every subsequent command needs --url=<site> or it will run against the network's main site.
Use it as a hook-respecting mutation layer, not a SQL shortcut. Every write goes through wp_insert_post(), wp_update_term(), etc., so plugin hooks, cache invalidation, revisions, and slug deduplication all behave exactly as they do in the admin. If you are currently doing DB::statement('UPDATE wp_posts ...') from Laravel, this is the safer replacement — at the cost of ~1–2s of WordPress bootstrap per invocation.
Wrap it, do not scatter it. In a Laravel codebase, put a thin WordPressCli service around Process that fixes --path, forces --format=json/--porcelain, applies --yes in non-interactive contexts, and maps non-zero exits to exceptions. Raw exec("wp post list") calls sprinkled around are how you end up with silent empty arrays when the binary is missing.
Success:/Warning:/Error: lines go to STDERR, records go to STDOUT. Do not 2>&1 and then json_decode; you will get parse failures the first time something warns. Check isSuccessful() separately.--field=ID as an alias for comment_ID/post_ID/term_id, but --fields= wants the real column names. Mixing the two silently drops columns.WP_Query/WP_Comment_Query/WP_User_Query, which have their own defaults. A "full export" that omits --posts_per_page=-1 or --number=<n> will quietly truncate. Add --format=count to a second call if you want a cheap assertion that you got everything.delete is not always delete. Posts and comments go to trash without --force. Bulk cleanup scripts that omit --force look successful and leave content in the trash bin forever.--yes in CI, and guard the destructive ones. wp site empty, wp post delete --force, wp db reset-adjacent flows will happily proceed non-interactively with --yes. Gate those scripts behind an explicit environment check rather than letting a deploy pipeline call them by accident.wp search-replace, wp db *, wp media *, wp transient *, wp cache *, wp cron *, and wp scaffold * live in other WP-CLI packages. Reaching for entity-command to do a URL migration or import an image will waste an hour.add appends (and allows duplicate keys), update creates-or-updates, set is the "just make it so" verb. wp comment meta get <id> <key> without --single returns an array even for a single value, which trips up strict === comparisons.--by. wp term update <taxonomy> <term> interprets <term> as an ID unless you pass --by=slug, and slug-vs-name ambiguity is the most common "it updated the wrong term" bug.entity-command version tracks your WP-CLI version, and newer features (e.g. the WordPress 6.9+ --comment_type=note workflows in the README) need both a recent WP-CLI and a recent WordPress. Pin wp cli update --version=x.y.z in CI images so scripts do not break on a host's PHP/WP-CLI pairing.wp post meta update calls takes ~15 minutes. Batch with wp eval-file (a PHP file that loops and calls the same APIs in one boot) when the count gets large; keep xargs for tens of items.README.md. Run the narrow check: composer phpcs -- <file>, composer phpstan, then composer behat -- features/<name>.feature:<line> with NO_COLOR=1 and WP_CLI_TEST_QUIET=1 exported. composer test reinstalls WordPress per Behat scenario and is a PR-time check, not a two-line-change check.How can I help you explore Laravel packages today?