wp-cli-patterns · git:20260917.7a8dbe1 · 2026-09-17 · sha256 db19fac82e435761

wp-cli-patterns git:20260917.7a8dbe1A

Immutable. This exact content is served forever at /api/v1/blob/db19fac82e435761.

---
name: wp-cli-patterns
description: WP-CLI-first principle — use WP-CLI instead of generating PHP code whenever possible, with command reference, ACF seeding patterns, and environment-aware execution
user-invocable: false
trigger: auto-invoke when .wp-create.json exists in project root
---

# WP-CLI Patterns — Best Practices for All Agents

This skill teaches the **WP-CLI-first principle**: use WP-CLI commands instead of generating PHP code whenever possible. WP-CLI saves tokens, reduces errors, and executes faster than writing throwaway PHP files.

---

## Core Rule: WP-CLI Over PHP Generation

**Always prefer a single WP-CLI command over generating PHP code.**

```
# BAD (costs tokens): Generate PHP file with update_option()
# GOOD (1 line):      $WP option update my_option 'value'

# BAD:  Generate PHP with wp_insert_post()
# GOOD: $WP post create --post_type=page --post_title='About' --post_status=publish

# BAD:  Generate PHP with wp_create_nav_menu()
# GOOD: $WP menu create "Primary EN" && $WP menu item add-post primary-en 5

# BAD:  Generate PHP to activate a plugin
# GOOD: $WP plugin activate secure-custom-fields

# BAD:  Generate PHP to set permalink structure
# GOOD: $WP rewrite structure '/%postname%/'
```

Use `wp eval` only when no dedicated WP-CLI subcommand exists for the operation (e.g., calling ACF's `update_field()` API).

---

## Non-Trivial Seed Logic Lives in `inc/seed/`, Never in a Scratchpad

The rule above is about a single throwaway operation. It does not cover
anything a project needs to **re-run** — content re-seeded after a later pass
recreates records (a translation import that builds new counterparts, a
taxonomy retrofit), a bulk import worth re-checking, or any script whose
inputs are worth keeping. Writing that kind of script into a session's
scratchpad has the same failure shape every time: the records it created land
in the database and survive, the code that reproduces them does not, and a
fresh clone of the repository — or a rollback — has no way to get them back.

- **The script goes in `<theme>/inc/seed/<name>.php`**, any data payload it
  needs in `<theme>/inc/seed/data/`, however small either looks at the time.
  Run it with `wp eval-file <path>`.
- **Every record it writes carries a marker** — post meta or term meta named
  `_<prefix>_seeded_content` — so a later run, or a later script, can tell
  which records are this script's own and which are the client's.
- **A record meant to be found again across runs carries a stable key** —
  `_<prefix>_seed_key` — instead of being re-identified by its numeric post
  ID. An ID is only meaningful on the install that generated it; a fresh
  install, a staging copy or a later environment has no way to match "record
  14" back to anything. Matching by the marker's key lets a second run update
  the same record in place instead of duplicating it.
- **A client's own edit always wins.** Compare before writing: if the target
  no longer carries the marker (or its content diverges from what the script
  last wrote), leave it alone. The point of the marker is exactly this
  comparison — without it, a re-run cannot tell "still ours to update" from
  "the client changed this on purpose," and silently overwrites the client's
  work.

This is unrelated to the WP-CLI-vs-PHP-generation rule above: a script that
belongs in `inc/seed/` should still prefer `update_field()` / WP-CLI functions
over hand-rolled SQL inside it — the two rules compose, they do not conflict.

---

## The `$WP` Convention

Throughout all commands, agents, and skills, **`$WP`** is shorthand for the value of `wp_cli.wrapper` from `.wp-create.json`. Agents read this value and substitute it into all WP-CLI commands.

For example, if the environment is Docker:

```bash
# $WP expands to:
docker exec my-project-wp wp --allow-root

# So this command:
$WP option update blogname "My Site"

# Becomes:
docker exec my-project-wp wp --allow-root option update blogname "My Site"
```

**How to read `$WP`:** Parse `.wp-create.json` at the project root and extract the `wp_cli.wrapper` value. Every WP-CLI command in this skill assumes `$WP` is set to that value.

---

## Environment-Aware Execution

Agents read `wp_cli.wrapper` from `.wp-create.json` and prepend it to all WP-CLI commands. The wrapper value depends on the environment type:

| Environment | `wp_cli.wrapper` value | Notes |
|-------------|----------------------|-------|
| Native | `wp --path=/var/www/html/my-project` | Direct CLI, requires WP-CLI installed on host |
| Docker | `docker exec my-project-wp wp --allow-root` | Executes inside WordPress container |
| DDEV | `ddev wp` | DDEV proxies to the web container |
| Lando | `lando wp` | Lando proxies to the appserver container |
| wp-env | `npx wp-env run cli wp` | wp-env proxies to its CLI container |

**Important:** Never hardcode the execution method. Always read the wrapper from the manifest so commands work across all environments.

---

## Environment Detection: Reading `.wp-create.json`

The `.wp-create.json` manifest is generated by `/wp-create` at the project root. It is the single source of truth for all commands, agents, and skills.

```bash
# Check if manifest exists
if [ -f .wp-create.json ]; then
    # Extract the WP-CLI wrapper
    WP=$(jq -r '.wp_cli.wrapper' .wp-create.json)

    # Extract other useful values
    PROJECT_SLUG=$(jq -r '.project.slug' .wp-create.json)
    PRIMARY_LANG=$(jq -r '.languages.primary' .wp-create.json)
    ADDITIONAL_LANGS=$(jq -r '.languages.additional[]' .wp-create.json)
    ENV_TYPE=$(jq -r '.environment.type' .wp-create.json)
fi
```

When `.wp-create.json` does **not** exist, WP-CLI features are unavailable. Fall back to file-only operations (the pre-existing behavior).

---

## WP-CLI Command Reference

All 16 domains agents should know. Every command below is prefixed with `$WP` in practice.

| Domain | Commands |
|--------|----------|
| Database | `wp db create`, `wp db import`, `wp db export`, `wp db check`, `wp db query` |
| Content | `wp post create`, `wp post update`, `wp post delete`, `wp post meta update` |
| Media | `wp media import <url>`, `wp media regenerate` |
| Options | `wp option get`, `wp option update`, `wp option delete` |
| Menus | `wp menu create`, `wp menu item add-post`, `wp menu item add-custom`, `wp menu location assign` |
| Plugins | `wp plugin install`, `wp plugin activate`, `wp plugin deactivate`, `wp plugin list` |
| Theme | `wp theme activate`, `wp theme list` |
| Config | `wp config set`, `wp config get`, `wp config list` |
| Rewrite | `wp rewrite structure`, `wp rewrite flush` |
| Cache | `wp cache flush`, `wp transient delete --all` |
| Cron | `wp cron event list`, `wp cron event run` |
| Search | `wp search-replace 'old' 'new'` |
| Scaffold | `wp scaffold child-theme`, `wp scaffold plugin` |
| Export/Import | `wp export`, `wp import` |
| User | `wp user create`, `wp user update` |
| Eval | `wp eval 'php_code();'` |

### Useful Flags

- `--porcelain` — return only the ID (useful for capturing post/attachment IDs)
- `--format=json` — machine-readable output for parsing
- `--format=table` — human-readable output for display
- `--allow-root` — required inside Docker containers running as root
- `--force` — skip confirmation prompts (e.g., `wp post delete 1 --force`)

---

## ACF Field Seeding Patterns

### Preferred: `update_field()` via `wp eval`

Use ACF's own API for field operations. This is storage-format-agnostic and handles field key registration, serialization, and caching correctly.

```bash
# Simple field on options page
$WP eval "update_field('hero_title', 'Building Digital Excellence', 'option');"

# Image field (import first, use attachment ID)
ID=$($WP media import 'https://images.unsplash.com/photo-xxx' --title='Hero Background' --porcelain)
$WP eval "update_field('hero_image', $ID, 'option');"

# Repeater field
$WP eval "
\$rows = array(
  array('title' => 'Web Design', 'description' => 'Custom websites...', 'icon' => 43),
  array('title' => 'SEO', 'description' => 'Search optimization...', 'icon' => 44),
);
update_field('services_cards', \$rows, 'option');
"

# Page post meta (field on a specific page)
$WP eval "update_field('about_hero_title', 'Our Story', <post_id>);"
```

### Alternative: Direct `wp_options` for Bulk Operations

Faster for bulk seeding but coupled to ACF internals. Use only when ACF API is unavailable or for bulk performance.

ACF stores options page fields in `wp_options` with an `options_` prefix (e.g., field `hero_title` is stored as `options_hero_title`).

```bash
# Simple field
$WP option update options_hero_title "Building Digital Excellence"
$WP option update options_hero_image 42

# Repeater fields (indexed subfields + count)
$WP option update options_services_cards_0_title "Web Design"
$WP option update options_services_cards_0_icon 43
$WP option update options_services_cards_1_title "SEO"
$WP option update options_services_cards_1_icon 44
$WP option update options_services_cards 2  # total row count
```

### After Seeding: Always Flush Cache

```bash
$WP cache flush
```

---

## Bilingual Naming Convention

This convention matches the i18n helper system defined in the `wp-bilingual` skill.

- **Primary language fields use no suffix:** `hero_title`, `hero_description`, `cta_text`
- **Secondary language fields append `_<lang>`:** `hero_title_es`, `hero_description_es`, `cta_text_es`

### Seeding Bilingual Content

```bash
# Primary language (no suffix)
$WP eval "update_field('hero_title', 'Building Digital Excellence', 'option');"

# Secondary language (append _<lang>)
$WP eval "update_field('hero_title_es', 'Construyendo Excelencia Digital', 'option');"
$WP eval "update_field('hero_subtitle_es', 'Creamos sitios web que funcionan', 'option');"

# Bilingual repeater subfields
$WP eval "
\$rows = get_field('services_cards', 'option');
\$rows[0]['title_es'] = 'Diseno Web';
\$rows[1]['title_es'] = 'SEO';
update_field('services_cards', \$rows, 'option');
"
```

### Rules

- Non-translatable fields (images, URLs, numbers, booleans) do NOT get language variants
- Read `.wp-create.json` field `languages.additional` to know which suffixes to generate
- If `languages.additional` is empty, skip all `_<lang>` field operations

---

## Common Patterns

### Always set an author

`wp post create` and `wp media import` leave `post_author` at **0** — a user
that does not exist. The post saves and renders, so nothing looks wrong until
something asks for the author: `the_author()` prints nothing, an Article schema
emits an empty `author`, the admin list shows a blank column, and a plugin that
dereferences the author object can fatal. Resolve an author once and pass it to
every create:

```bash
AUTHOR=$($WP user list --role=administrator --field=ID --number=1)
$WP post create --post_author=$AUTHOR ...
$WP media import file.jpg --post_author=$AUTHOR ...
```

Sweep at the end of any seeding run — it must print 0. Two exclusions, or the
sweep fails on a site that is perfectly seeded: WordPress creates auto-drafts with
`post_author = 0` on its own, and menu items get the same treatment because
`wp_insert_post()` falls back to `get_current_user_id()`, which is 0 under WP-CLI —
a menu item has no author to display, so it is noise here rather than a defect.

```bash
$WP db query "SELECT COUNT(*) FROM $($WP db prefix)posts WHERE post_author = 0 AND post_status != 'auto-draft' AND post_type != 'nav_menu_item';"
```

### Create Pages and Set Front Page

```bash
AUTHOR=$($WP user list --role=administrator --field=ID --number=1)
HOME_ID=$($WP post create --post_type=page --post_title='Home' --post_status=publish --post_author=$AUTHOR --porcelain)
ABOUT_ID=$($WP post create --post_type=page --post_title='About' --post_status=publish --post_author=$AUTHOR --porcelain)

$WP option update show_on_front 'page'
$WP option update page_on_front $HOME_ID
```

### Create and Assign Menus

```bash
$WP menu create "Primary EN"
$WP menu create "Primary ES"

$WP menu item add-post primary-en $HOME_ID --title="Home"
$WP menu item add-post primary-en $ABOUT_ID --title="About"
$WP menu item add-post primary-es $HOME_ID --title="Inicio"
$WP menu item add-post primary-es $ABOUT_ID --title="Acerca"

$WP menu location assign "Primary EN" primary_en
$WP menu location assign "Primary ES" primary_es
```

### Import Media and Use Attachment ID

```bash
ID=$($WP media import 'https://example.com/photo.jpg' --title='Hero Image' --porcelain)
$WP eval "update_field('hero_image', $ID, 'option');"
```

### Verify Operations

```bash
# Verify a field was seeded
$WP eval "echo get_field('hero_title', 'option') ? 'OK' : 'EMPTY';"

# Verify a page exists with correct template
$WP eval "echo get_page_template_slug($PAGE_ID);"

# Verify plugin is active
$WP plugin list --status=active --format=table

# Verify menus are assigned
$WP menu location list --format=table
```

### Final Cleanup After Seeding

```bash
$WP rewrite flush
$WP cache flush
```