The taw commandContent Interchange

Content Interchange

TAW's native tool for moving site state between environments — posts, fields, options, terms, media, and optionally users, comments and settings — as one reviewable, diffable, rollback-able JSON file.

Move site state, not a database dump

Content Interchange is TAW's native way to move a site's state between environments — a snapshot you can read, diff, review, and roll back, instead of a .wpress black box or a 400 MB .sql.

It works because a disciplined TAW site keeps everything in _taw_ fields and options instead of a plugin stack: no page builders, no ACF, menus in code, forms as runtime taw_submission entries. What a generic migration tool has to haul — dozens of plugin tables, serialized builder blobs, orphaned option rows — simply isn't there. So the site's real state fits in a single JSON file that a person (or a code agent) can open and understand.

taw content:export --migrate --output=/tmp/site.json   # whole-site state
taw content:import /tmp/site.json                       # dry-run: what would change
taw content:import /tmp/site.json --yes                 # apply (rollback snapshot first)

Migrate state, deploy code. Content Interchange never touches theme or plugin PHP, or the database's binary internals. Code travels through git and your deploy pipeline; this tool moves the content and configuration that lives in the database. Single-site only — multisite is out of scope.

When to use what

You want to…Use
First clone of a site · new host · disaster recovery · stand up staging from nothingA full-site tool (Duplicator, All-in-One WP Migration, WP Migrate, WPMU DEV Shipper) or content-sync pull --strategy=db
Keep two existing TAW installs' content in step — and, with --migrate, their config tooContent Interchange
Promote specific pages / options from staging to productionContent Interchange (--posts=, --types=, or a change-set)
Editorial sandbox → liveContent Interchange
Agent-mediated bulk edits (export → transform → diff → review → apply)Content Interchange
Seed a brand-new TAW site with a starter content setContent Interchange

The dividing line: a full-site tool clones an entire WordPress install, database and uploads and all, and is the right call when there's no site there yet or you're recovering one. Content Interchange moves the reviewable delta between two installs that already exist and already run the TAW stack.

What's in a snapshot

TAW\Core\Content\Exporter::snapshot($scope) produces a plain array, json_encode-ready. schema is "1.9" (taw/core v1.100.0+); the importer also accepts "1.0"–"1.8", and warns when a file's minor version is newer than its own. Full JSON Schema: resources/schema/content-interchange-1.9.json in the taw/core package.

  • 1.9 names a post's featured image by its path under uploads (2026/06/hero.webp), not its file name, so two files with one name in different month folders stay apart. An older snapshot's bare name still works when it names one file; when it names several, the post keeps its featured image and the import warns, instead of picking one.

  • 1.8 is for publishing: with --media-hashes, each media entry says what its file holds (file, sha256, bytes), and a change-set made with --with-base carries each changed record as the target last had it (base), so an import can tell what the target changed since. See Review details, conflicts and publishing.

  • 1.7 carries the site icon and logo, the classic menus built in wp-admin and which location each sits in (menus, theme_mods), reusable blocks and block navigation menus, and footnotes. Menus a theme builds in code are left alone. Other plugins' post meta (an SEO plugin's titles) travels only with --meta=<prefix>, e.g. --meta=_wds_ for SmartCrawl. Templates, template parts, global styles and widgets stay out: they're code-owned in TAW themes.

  • 1.6 adds meta.scope, which records what a scoped export left partial (posts: partial, terms: used, options: false). Change-sets carry the source, refs and the media their operations reference (see "Push a change-set to another site" below).

  • 1.5 keys pages (any hierarchical type) by their path, the parents' slugs and its own as in the page URL (about/team), so two pages with one slug under different parents stay two records, and names a page's parent by its path. Term meta values are exported unserialized, and the terms of every taxonomy the exported posts use are exported, not only public taxonomies.

  • 1.4 adds refs: the natural keys of every post, term and user the snapshot references by numeric ID (post_select values, and IDs in block attributes), keyed by the source ID, e.g. "refs": {"posts": {"135": {"type": "case_study", "slug": "studio"}}}. It also adds meta.source.uploads_url, and media also covers images in block attributes and files linked by URL in the uploads folder (a PDF in a link field).

  • 1.3 adds a post's password, a comment's post_type, keeps empty taxonomies as [] (so removed terms are cleared), and keys slug-less drafts by their local date.

Field keys: a record's fields are keyed by the bare field id for _taw_ fields ("hero_heading"), exactly as in 1.0 and 1.1, and by the full meta key for fields with any other prefix ("_book_author"). Each value is decoded with the config of the field registered for that post type, taxonomy or users, so two fieldsets sharing a field id each keep their own type. Before 1.2, fields with another prefix were left out.

SectionAlways carriedContents
metayesschema, generated_at, source (URL, taw/core version, theme), and a registry_fingerprint (block IDs + field_id → type) so the importer can warn when the target's registry has drifted
optionsyesEvery _taw_* option (repeater / files decoded to arrays), plus a core allowlist — blogname, blogdescription, show_on_front, page_on_front / page_for_posts (as slugs)
termsyesPer public taxonomy, and every taxonomy the exported posts use, except nav_menu: slug, name, description, parent (by slug), meta (keys not starting with _, unserialized), plus fields (decoded, keyed like post fields) when the taxonomy has term fieldsets
postsyespage / post, every public CPT, and every CPT with a Metabox attached — except taw_submission and framework-internal types. Per post: type, slug, path (pages and other hierarchical types), status, title, excerpt, content, menu_order, date, author ({login, email}), comment_status, ping_status, parent (by path for pages, else by slug), template, terms, featured_media (filename), fields (each field's value decoded by its registered type, any prefix)
mediayesEvery referenced attachment: id, ref / filename, url, title, description, alt, caption, mime
usersopt-in — --with-userslogin, email, display_name, roles[], meta (first_name, last_name, description, nickname, locale), user_registered, plus fields when a fieldset targets users. Password hashes only with the separate --with-user-passwords
commentsopt-in — --with-commentsComments on exported posts: post_ref (slug), author fields, content, date_gmt, approved, type, parent_ref (in-file id for threading)

Never carried: revisions, transients, non-allowlisted core / plugin options, nav_menu / nav_menu_item (code-owned in TAW themes), post meta that no fieldset registers. Drafts are excluded unless --include-drafts.

Scope flags

--posts, --types and --since make the export scoped (taw/core v1.85.0+): it leaves out options and keeps only the terms its posts use, and their parents, so importing "just these posts" never overwrites the rest of the site's settings or categories. --with-options and --with-terms (REST include_options / include_terms) bring them back. taw-fleet pull exports in full.

FlagEffect
--types=page,postLimit to these post types
--since=2026-01-01Only posts dated on or after this date
--posts=12,aboutOnly these posts (IDs or slugs)
--meta=_wds_Also carry post meta whose keys start with these prefixes (comma-separated); other plugins' meta is left out otherwise
--with-optionsWith --posts / --types / --since: also export options (left out by default)
--with-termsWith --posts / --types / --since: export every term, not only those the posts use
--no-mediaOmit the media section
--all-mediaAlso export attachments not referenced by any exported post / field
--include-draftsInclude draft / pending posts (slug-less ones get a composite match key)
--with-usersAdd the users section (no password hashes)
--with-user-passwordsInclude portable password hashes (implies --with-users)
--with-commentsAdd the comments section
--with-settingsAdd the environment-settings option allowlist (see below)
--migrate= --with-users --with-settings --all-media --include-drafts

Filters

Each of these belongs in the theme's inc/customizations.php.

// Add a non-content CPT to the export (rarely needed — a CPT with a Metabox
// attached is auto-included as of taw/core v1.26.0).
add_filter('taw_content_export_post_types', fn (array $types) => [...$types, 'event']);

// Extend the always-on core option allowlist.
add_filter('taw_content_export_core_options', fn (array $keys) => [...$keys, 'my_theme_accent']);

// Extend the --with-settings environment allowlist.
add_filter('taw_content_export_settings_options', fn (array $keys) => [...$keys, 'rss_use_excerpt']);

Sites that added a manual taw_content_export_post_types filter just to export a public => false content CPT (for example an event schedule) can drop that filter once running taw/core v1.26.0 — any CPT with a Metabox attached is auto-included.

The --with-settings allowlist

A second, opt-in allowlist — separate from the always-on core one — for environment-ish settings a full migration wants but a routine content sync must never touch: permalink_structure, timezone_string, gmt_offset, date_format, time_format, start_of_week, sticky_posts (exported as slugs), blog_public, default_comment_status, default_ping_status, WPLANG.

These are import-gated as well — content:import skips them unless you also pass --with-settings (or tick the box on the admin screen).

Import — dry-run mandatory, rollback automatic

TAW\Core\Content\Importer consumes a snapshot or a change-set ({ taw_changeset, operations: [...] }, which content:diff emits). Records match by natural key, never by numeric ID:

  • posts by (type, slug); pages and other hierarchical types by (type, path) (1.5); a slug-less draft by (type, match_key). Drafts and private posts match too, even from the taw command with no user (taw/core v1.52.1+)
  • options by key · terms by (taxonomy, slug)
  • users by login, then email · comments by a content hash

Apply order

Dependency-first and deterministic:

users → terms → media (sideload + build old→new ID map) → posts → comments → settings

What happens on apply

  1. Rollback first. A full, maximal-scope Exporter snapshot (users, settings, all media, drafts) is written to wp-content/uploads/taw-private/ — an .htaccess-denied directory — and its path is named in the report, along with an import journal (import-*.json, v1.86.0+). If either can't be written, nothing is imported. A record that fails is listed under failed and the rest of the import carries on.
  2. Media is matched to an existing attachment by its path under uploads (2024/05/photo.jpg), then by filename, or sideloaded from url with its title / description / alt / caption (a caption keeps its markup). A downloaded file goes into the source's month folder, so two files that share a name stay two files; a -scaled image is downloaded as its original, and WordPress makes the same scaled copy; the filename match never takes a file downloaded from another address; a download is tried three times (v1.87.2–v1.88.0). A matched attachment whose alt text, caption, title or description differ is updated (v1.83.0). The dry run lists media first, as media:<path> records: create (would be downloaded), update (metadata differs), missing (not here and no URL). A file the snapshot lists that this site can't get is cleared wherever it's referenced, never left pointing at an unrelated attachment with the same number.
  3. References and URLs are mapped to this site's (1.4), before the diff and the write:
    • attachment IDs by the media match; post, term and user IDs through refs, by slug, login or email. That covers post_select values (also in repeaters, term and user fields, and options), image / files values, and the IDs in block attributes: images, galleries, covers, media-text, files, video, audio, navigation links, page lists, latest posts, query loops and taw/loop sources. Other blocks' attributes are never touched, and content with nothing to map is kept byte for byte;
    • a post the snapshot names but this site lacks is dropped with a warning, never left pointing at an unrelated post. A post created later in the same import is linked on a second pass, and a page or term already here whose only change is such a link shows as links in the dry run and is written in the same run (v1.88.0; before, the link came with the next import);
    • each media file's URL (with its sizes) becomes its local URL, then links to the source site become links to this one, in content, excerpts, every text field and TAW options. A file in the source's uploads that this site doesn't have keeps its source URL, so it still loads. The scheme maps both ways (v1.100.0): a link in the source's own scheme takes this site's, one in the other scheme takes the other. When both sites use the same scheme, an http:// link to an https site stays http://, and a pull then a publish gives back what it started from;
    • so importing a snapshot from another site twice reports nothing to change the second time.
  4. Portable transforms are reversed. page_on_front / page_for_posts / sticky_posts (slugs; a front page neither on the site nor in the import keeps its current value, never 0; one the import brings is set, even on a site that had none), post.parent (path or slug), featured_media (path under uploads) and author ({login,email}) all resolve back to a local ID before the diff and the write. Setting a featured image leaves one _thumbnail_id row, even where an old migration left several.
  5. Posts via wp_insert_post / wp_update_post, written as an admin's save would be: kses is lifted for the run (embeds, SVG and forms survive; & stays &), and content:import runs as a user (--user, default the first administrator). Field meta goes through Metabox::writeMeta(), the same sanitize + wp_slash path an admin metabox save uses; a field nothing registers on the target is written as it came. Password, date (both columns) and terms per taxonomy travel. Author resolves login → email; when this site lacks it, a post already here keeps its author and a new post gets the importing user (both with a warning; before v1.100.0 existing posts went to the importing user too). A failed write is reported and nothing else is written for that post.
  6. Users via wp_insert_user / wp_update_user. Roles are sanitised against the roles the target site actually defines — a role the target doesn't have is never granted, and a user whose source roles don't exist here keeps the role they have. New accounts get a random password unless a portable hash was exported.
  7. Comments are recreated once (matched by their own date, content and author email), remapped to their post by type and slug, with their dates kept, threading rebuilt from parent_ref, counts recomputed. A change-set delete deletes.
  8. Settings options — only if --with-settings.

Undo (v1.86.0). The journal records what the import actually changed: for every post, attachment, term, option, user and comment it touched, the values before and after, plus what it created and deleted (downloaded files included). content:import --undo previews undoing the last import, --undo --yes does it, and --undo=import-….json picks another; Tools → TAW Data shows an Undo this import button on the import report. A value goes back only while it still equals what the import left, so anything edited since is kept (and listed). What the import created is deleted unless edited since, and what it deleted comes back.

Page and category trees (1.5, v1.84.0). Parents are applied before their children, pages and terms alike, whatever order the source lists them in, and a parent removed at the source is removed here. A page that isn't at its path on this site is paired, for the whole import at once, with this site's page of the same slug that isn't at any path of the import (a page moved at the source): one of each, or by a title only one page on each side has. Anything else is created, never written over another page. A parent that's neither on this site nor in the import shows in the dry run's warnings instead of as a change. Term meta is compared (meta.<key>) and written; an older snapshot's serialized value is decoded (never into objects).

A clean --migrate export → import --yes against the same site reports 0 created / 0 updated / 0 deleted. plan() reports zero changes and apply() skips every record the dry-run shows unchanged, so re-running an import never churns post_modified dates.

Conflict policy

Per-record update (overwrite) / create (only new) / skip — global default via --policy= or the admin selector.

Review details, conflicts and publishing

taw/core v1.98.0+ makes the dry run say exactly what would change, and adds what publishing from one site to another needs.

Each change names its field. A plan record has a record_key (post:page:about, term:category:news, option:_taw_phone), a title, a hash of its changes and, for a post already on the site, its edit_url. Each change has a label; a TAW field's change also has its type, its fieldset's title and its block, so content:import's table and Tools → TAW Data show it as Fieldset › Label. An image or files change lists the old and new files.

Values changed on both sides. Make the change-set against the target's own content with content:diff target.json source.json --with-base, and the import compares each value three ways: the base (the target as the source last had it), the target now, and the incoming value. A change then says whether the target moved away from the base since (conflict) and whether the incoming value did (local_changed), with the base value. Or import a full snapshot with content:import file.json --base=target-before.json: only what changed since the base is imported.

Keep the target's value. --keep=<record key>:<change key> (repeatable) leaves that value as the target has it: not listed, not written. Tools → TAW Data offers Keep this site's or Use the file's on each value changed on both sides, and a checkbox per record.

Files from disk. --staged=<folder> takes media files from a folder (by their path under uploads, yyyy/mm/name) instead of downloading them. With --media-hashes on the export, a file only matches one on the target holding the same bytes; a different file at the same path comes in as a new file.

Undo preview. content:import --undo without --yes says what undoing would put back, delete and recreate, and which values it would keep because they were edited since. content:journals lists every import, undone ones too.

Publishing. TAW\Core\Content\Publish runs a reviewed publish in steps: plan (writes nothing but a pending folder, and answers a one-time token), upload the files the plan asks for (matched by sha256), apply the approved records (planned again first: if one changed since the review, nothing is written), and undo. The TAW Companion 0.5 exposes it over signed routes, and taw-fleet drives it with a review page in your browser: see Publish content to production. Users, comments and site settings are never published.

Media go only where the approval says (taw/core v1.99.1+): an approved record brings the files it uses, but an image's alt text, title, caption or description changes only when its own media: record is approved, and a file only unapproved records use is never uploaded.

Workflow recipes

Refresh a local dev site from production

On production (or via the REST route from CI):

taw content:export --migrate --output=prod.json

On the local site:

taw content:import prod.json            # review the diff
taw content:import prod.json --yes --with-settings

Promote specific pages from staging to production

# on staging
taw content:export --posts=pricing,features --output=promo.json
# on production
taw content:import promo.json --yes

Editorial sandbox → live

Export the sandbox, import to live with --policy=update. Media referenced by the edited posts sideloads automatically; nothing else on live is touched.

Push a change-set to another site

A change-set from content:diff carries the source, its references and the media its operations use (v1.85.0+), so on another site it maps IDs, rewrites links and downloads files like a snapshot import. Diffing scoped snapshots deletes nothing outside their scope.

Agent-mediated bulk edit

taw content:export --output=before.json
cp before.json after.json          # agent rewrites fields in after.json
taw content:diff before.json after.json --out=changes.json
taw content:import changes.json           # review
taw content:import changes.json --yes     # apply just the change-set

Full TAW-site move

taw content:export --migrate --with-comments --output=site.json
# stand up the new install with the theme deployed via git, then:
taw content:import site.json --yes --with-settings

Deploy the theme and any plugins through your normal pipeline — this file carries state, not code.

How it's tested

Every change to taw/core runs the fidelity suite in CI: a real round trip between two WordPress sites. Site A holds everything an import has to carry by natural key rather than by ID: pages three levels deep, two pages with the same slug, two files with the same name in different month folders, blocks that reference images, posts, terms and reusable blocks, IDs inside groups and nested repeater rows, menus, the site icon and logo. Site B starts with different IDs and some of A's records already there, older. After A is imported into B, the suite checks that:

  1. a second import reports nothing to change;
  2. every value on B points at the same record it does on A, read straight from each database;
  3. B's export, imported back into A, changes nothing;
  4. undo returns B to exactly where it was.

Since v1.98.0 it also publishes A's edits to B the way taw-fleet does: a value edited on both sides is flagged, a kept value stays B's, files come from staged bytes with no download, and undo puts B back.

Run it yourself from a taw-core checkout with composer run fidelity (WP-CLI and a MySQL server; see the README's Fidelity suite).

The admin screen — Tools → TAW Data

For non-CLI use, Tools → TAW Data (capability export; import needs taw_import_content, granted to anyone with manage_options).

  • Export — a button plus checkboxes mirroring the CLI flags: referenced media, unreferenced media, drafts, users, environment settings. Password hashes and comments sit behind an Advanced disclosure with a warning. Streams taw-content-<host>-<timestamp>.json.
  • Import — upload a snapshot or change-set → a mandatory review table (per record, per field by its Fieldset › Label: unchanged / changed / new / would-delete) with a conflict-policy selector and an "also apply environment settings" checkbox → an explicit "Apply N changes" button. There is no one-click apply. Since v1.98.0, each record has a checkbox (untick it to leave it out), and a value changed on both sides shows the base value with Keep this site's / Use the file's.
  • Every apply writes the rollback snapshot to uploads/taw-private/ first and links it in the result notice.
Tools → TAW Data export screen with scope checkboxes
Tools → TAW Data export screen with scope checkboxes
Import review table showing per-field changes before applying
Import review table showing per-field changes before applying

Defaults into records

When a theme shows content from its fields' defaults (taw/core v1.80.0+), that content is in code, not in the database, so it can't be edited in wp-admin and isn't exported. Saving the defaults turns it into records:

taw content:defaults                 # preview: every empty field and what it would get
taw content:defaults --apply         # write them (a journal goes first)
taw content:defaults --undo=defaults-20261009-120000.json

Or Tools → TAW Data → Defaults: the count, a table of the fields, and a Save N defaults to the database button, then Undo it for the last save.

  • Only empty fields are written, on the posts each metabox applies to (its post types, templates and slugs) and on options pages. Fields with a value are never touched.
  • The journal in uploads/taw-private/ lists exactly what was written. Undo restores those fields, and keeps any field edited since.
  • A default the field's sanitizing would change (tags in a text or textarea field) is marked * in the preview: saving stores the sanitized value.

REST export

GET /wp-json/taw/v1/content/export — the same snapshot the Export button produces, for CI or a headless build step. Capability export. Parameters: types, since, include_media, include_drafts.

The REST route is content-only — it never returns users, comments or settings, and there is no REST write route. User and settings export, and every import, happen on the CLI or the admin screen, where the migration intent is unmistakable and the dry-run and rollback live.