Skip to content

Page packages#

A page package moves selected posts (pages, posts, any post type) with everything they need to render on another site, without touching the rest of the database. It is on the Pages tab and in wp bb-migrate pages.

wp bb-migrate pages push "<connection>" about-us pricing
wp bb-migrate pages export about-us --file=/tmp/about.zip
wp bb-migrate pages import /tmp/about.zip

Selecting posts#

An item can be given as:

Form Example Resolved as
Post ID 1523 That post
Slug about-us The first post with that slug, any status, in WordPress's any post type query
URL https://example.com/company/about-us/ The post WordPress maps the URL to
Attachment ID, upload path or file URL 2026/10/poster.jpg That attachment on its own

An attachment as an item travels as media only: the file is copied and registered in the destination's media library, or reused when the file is already registered there. Revisions are never packaged.

What a package contains#

Part Contents
meta The source's home URL, site URL, uploads URL and uploads directory, the generation time, the plugin version, whether ACF was active
posts One entry per post: the post fields, the author's login, the parent's slug and type, every meta row as its raw database string, the terms, the page template, and a map of ACF fields whose values are IDs
attachments One entry per referenced attachment: its post fields, upload path, size, MD5, alt text and attachment metadata
files The relative upload paths the destination needs

Posts and their parent chain#

Each selected post is packaged, and so is its parent, the parent's parent, and so on, so the hierarchy can be rebuilt. Parents are ordered first. Each entry carries a role: selected, parent or related.

Meta#

Every meta row travels except editor housekeeping keys (_edit_lock, _edit_last, _wp_old_slug, _wp_old_date, _pingme, _encloseme, the trash keys and _wp_desired_post_slug). Values are packaged as the raw database strings and unserialized on import without instantiating objects.

Terms#

For every taxonomy of the post type: each assigned term's taxonomy, slug, name, description and parent slug. On import a term is matched by slug, or created (under its parent when the parent exists). A taxonomy that does not exist on the destination produces a warning and its terms are skipped.

Referenced attachments#

An attachment is included when the post refers to it in any of these ways:

  • the featured image (_thumbnail_id),
  • an ACF image, file or gallery field,
  • an ACF relationship, post object or page link field whose value is an attachment,
  • an upload URL anywhere in the content or in any meta value, for common media extensions (images, svg, pdf, video, mp3, zip, json, lottie); any generated size of an image resolves to its original,
  • a wp-image-<id> class or a [gallery ids="…"] shortcode.

Only attachments whose file is a valid path inside uploads are packaged.

ACF field-type aware ID remapping#

IDs differ between sites, so every reference is rewritten on import. ACF stores, next to each value, a hidden _<key> row holding the field key. The package uses it to learn each field's type and records the fields whose values are IDs: image, file, gallery, relationship, post_object, page_link.

On import:

Reference Rewritten to
_thumbnail_id The destination's attachment ID
ACF image, file, gallery values (single or array) Destination attachment IDs
ACF relationship, post object, page link values Destination post IDs (or attachment IDs) for posts in the package; a second pass covers posts imported later in the same package
wp-image-<id>, data-id="<id>", [gallery ids="…"] in content and meta Destination attachment IDs
The source's home URL, uploads URL and uploads directory in any string The destination's, with the serialized-safe replace

Without ACF on the source, the URL and class based rewrites still apply.

Options#

Option CLI flag Values Effect
Status --status=<status> private (default), draft, keep The status imported posts get. keep carries the source status, which publishes a post that was published at the source
If the post exists --on-exists=<mode> new (default), update, skip What to do when a post with the same slug and type exists on the destination
Related posts --related flag Also package posts referenced by relationship, post object and page link fields
Image sizes --with-sizes flag Transfer the generated image sizes instead of regenerating them on the destination
Regenerate --regenerate flag Rebuild image sizes on the destination even when they arrived or already exist

--status#

  • private and draft set that status on every imported post.
  • keep uses the source's status for a post that is created. For a post that is updated in place, keep leaves the destination's current status alone.

--on-exists#

Mode Behaviour
new Creates the post alongside the existing one. WordPress appends -2 to the slug; a person compares and swaps
update Overwrites the existing post's fields. Every meta key present in the package is deleted and written again; meta keys the package does not carry are left
skip Leaves the existing post, and uses it as the target for references

--on-exists applies to selected posts. A parent or related post that already exists on the destination is always used as it is and reported as matched (parent) or matched (related).

On the command line, pages push --on-exists=update asks for confirmation unless --yes. In the admin screen it asks you to type the remote's host name.

With --related, posts referenced through ACF relationship, post object and page link fields join the package with the role related. References found in those posts are followed in turn.

--with-sizes and --regenerate#

By default only the original file of each attachment travels and the destination generates its own image sizes for a new attachment. --with-sizes sends the generated sizes too; their metadata is accepted when every size file is present and has a safe file name, otherwise the sizes are regenerated. --regenerate forces regeneration for new and reused attachments alike.

How existing posts and attachments are matched#

Posts are matched by slug and post type. Trashed posts and auto-drafts are ignored. When several posts share the slug and type, the one under a parent with the same slug as the source's parent wins, then the oldest published one, then the oldest.

Attachments are matched by file path: the upload-relative path stored in _wp_attached_file, for example 2026/10/poster.jpg. A match is reused (its alt text is updated when the package has one); otherwise an attachment is created for the file. If the file is not on disk when the import runs, the import warns Missing file for attachment … and continues.

Authors are matched by login. When no user has that login, the post is assigned to the current user, or to user 1.

Parents are resolved to the parent imported from the same package or, failing that, to an existing post with the parent's slug and type. If neither exists the post is imported at the top level, with a warning.

Delivery#

Export and import as a zip#

wp bb-migrate pages export about-us 1523 --file=/tmp/pages.zip
wp bb-migrate pages import /tmp/pages.zip --status=private

The zip holds package.json and files/<upload path> for each file. Without --file it is written to the storage folder; the admin screen offers it as a download. On the other site, upload it on the Pages tab (or pass the path to pages import). Files are extracted into uploads, skipping any that already exist with the same size and any path the uploads rule refuses. ZipArchive is required.

This is the path to a site whose remote access stays off: nothing but an administrator's upload is involved.

Push and pull over REST#

wp bb-migrate pages push "<connection>" about-us --on-exists=update --status=keep
wp bb-migrate pages pull "<connection>" https://staging.example.com/company/careers/
wp bb-migrate pages list "<connection>" --search=about --type=page
Step Push Pull
1 This site builds the package The remote builds the package and returns it
2 Every file in the package is sent to the remote's uploads scope Files missing here, or whose original differs in size, are fetched from the remote's uploads scope
3 The package is posted to the remote, which imports it in one request This site imports the package
Remote switch allow_push allow_pull

pages list searches the remote's posts (needs allow_pull). It returns at most 40 rows, most recently modified first, with an exact slug match on top. With --type omitted it searches public post types.

The result#

Each import reports one row per post and a summary of attachments:

+---------+------+------+----------+----------------------------------------------+
| action  | from | to   | slug     | url                                          |
+---------+------+------+----------+----------------------------------------------+
| created | 1523 | 88   | about-us | https://staging.example.com/about-us/        |
+---------+------+------+----------+----------------------------------------------+

action is created, updated, skipped, matched (parent) or matched (related). Warnings (a missing file, a missing taxonomy, a parent not found, a refused snippet) are listed in the log. After an import the destination fires bb_cache_purge_all and flushes its object cache.

Imports run as the site's first administrator when the request has no user (a signed request, WP-CLI). Without a user, WordPress filters post content as untrusted HTML, which rewrote > as &gt; and stripped script tags from pushed content.

Snippet posts (Custom CSS & JS)#

The Custom CSS & JS plugin stores each snippet as a post of type custom-css-js, and prints CSS and JS snippets from files under uploads/custom-css-js/ that it writes only when a snippet is saved on its own edit screen. A snippet that arrives any other way is invisible until those files exist. BB Migrate handles this for packages.

Push snippets by ID, updating in place and keeping their status. On the destination they are matched by slug and type like any other post, so differing IDs between the two sites do not matter:

wp bb-migrate pages push "<connection>" 1412 1413 --on-exists=update --status=keep

Select snippets by ID rather than by slug: a slug is looked up with WordPress's any post type query, which leaves out post types that are excluded from search.

Validation#

A snippet's options meta decides the name of the file the snippet plugin writes. Since 0.2.2 a pushed snippet is imported only when its options describe a real snippet:

  • language is css, js or html,
  • preprocessor, when present, is none, less or sass,
  • linking, when present, is internal or external,
  • the preprocessor ID, when present, is a plain name (letters, digits, _, -) with an optional .scss, .sass, .less or .css extension.

Anything else is skipped with the warning Snippet <slug> skipped: its options are not a css/js/html snippet (language, preprocessor or ID). Without the check, language=php would have produced a PHP file under uploads with "Accept push" alone.

Regeneration#

After the posts are imported, the snippets that arrived are regenerated on the destination:

  1. For each published snippet, the served file is rewritten from the post content and options. HTML snippets have no file (the plugin prints them from the post), unless an old file exists, which is refreshed.
  2. The snippet plugin's search tree is rebuilt. This is what decides which snippet loads where, and it honours a snippet deactivated at the source.
  3. The URL-rules index is rebuilt: the custom-css-js-urls option, which maps each "where on site" rule to the snippets it applies to. The snippet plugin rebuilds it only from its own save screen, so before this step existed a pushed snippet with URL rules never printed.
  4. The cache is purged.

Only published snippets get a file. A snippet imported as private or draft produces the warning Some snippets were not regenerated: only published snippets get a file (import them with status "keep"), and HTML snippets have none.

When snippets arrive by a database push instead of a package, run the same regeneration as a site action: wp bb-migrate site snippets.

Practical limits#

  • One request per import. On a push, the remote imports the whole package inside a single HTTP request. The sender waits up to 600 seconds, but a proxy in front of the remote may not: Cloudflare cuts a response after 100 seconds. The import may continue on the origin or stop part-way; either way the sender sees an error and gets no report. Send large sets in small batches (a few dozen posts per push), or use a connection string that reaches the origin directly. Then check the result with pages list.
  • Package files are not diffed on a push. Every file the package lists is sent again. Large media that is already on the destination is better left out of the selection, or sent once with file sync, which compares first.
  • pages list shows 40 rows. Use --search and --type to narrow it.
  • Slug lookups cross post types. A bare slug resolves to the first post with that slug, whatever its post type. Use an ID or a URL when a slug is not unique.