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 nothing | A 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 too | Content Interchange |
| Promote specific pages / options from staging to production | Content Interchange (--posts=, --types=, or a change-set) |
| Editorial sandbox → live | Content Interchange |
| Agent-mediated bulk edits (export → transform → diff → review → apply) | Content Interchange |
| Seed a brand-new TAW site with a starter content set | Content 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-basecarries 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,refsand 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'sparentby its path. Termmetavalues 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_selectvalues, and IDs in block attributes), keyed by the source ID, e.g."refs": {"posts": {"135": {"type": "case_study", "slug": "studio"}}}. It also addsmeta.source.uploads_url, andmediaalso 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'spost_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.
| Section | Always carried | Contents |
|---|---|---|
meta | yes | schema, 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 |
options | yes | Every _taw_* option (repeater / files decoded to arrays), plus a core allowlist — blogname, blogdescription, show_on_front, page_on_front / page_for_posts (as slugs) |
terms | yes | Per 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 |
posts | yes | page / 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) |
media | yes | Every referenced attachment: id, ref / filename, url, title, description, alt, caption, mime |
users | opt-in — --with-users | login, 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 |
comments | opt-in — --with-comments | Comments 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.
| Flag | Effect |
|---|---|
--types=page,post | Limit to these post types |
--since=2026-01-01 | Only posts dated on or after this date |
--posts=12,about | Only 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-options | With --posts / --types / --since: also export options (left out by default) |
--with-terms | With --posts / --types / --since: export every term, not only those the posts use |
--no-media | Omit the media section |
--all-media | Also export attachments not referenced by any exported post / field |
--include-drafts | Include draft / pending posts (slug-less ones get a composite match key) |
--with-users | Add the users section (no password hashes) |
--with-user-passwords | Include portable password hashes (implies --with-users) |
--with-comments | Add the comments section |
--with-settings | Add 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 thetawcommand with no user (taw/corev1.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
- Rollback first. A full, maximal-scope
Exportersnapshot (users, settings, all media, drafts) is written towp-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. - Media is matched to an existing attachment by its path under uploads (
2024/05/photo.jpg), then by filename, or sideloaded fromurlwith 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-scaledimage 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, asmedia:<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. - 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 coverspost_selectvalues (also in repeaters, term and user fields, and options),image/filesvalues, and the IDs in block attributes: images, galleries, covers, media-text, files, video, audio, navigation links, page lists, latest posts, query loops andtaw/loopsources. 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
linksin 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 stayshttp://, 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.
- attachment IDs by the media match; post, term and user IDs through
- 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) andauthor({login,email}) all resolve back to a local ID before the diff and the write. Setting a featured image leaves one_thumbnail_idrow, even where an old migration left several. - 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&), andcontent:importruns as a user (--user, default the first administrator). Field meta goes throughMetabox::writeMeta(), the same sanitize +wp_slashpath 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. - 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. - 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-setdeletedeletes. - 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:
- a second import reports nothing to change;
- every value on B points at the same record it does on A, read straight from each database;
- B's export, imported back into A, changes nothing;
- 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.


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
textortextareafield) 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.