Skip to content

Multisite content and media copy

Bulk actions that copy pages (with their ACF flexible content and images) and media library items from one subsite to another. Added because the country sites share most of their page content.

The UK site is the content source; Ireland, USA and Australia reuse a large part of it with local edits. Without this, launching a new country site means rebuilding every page by hand, and ACF flexible content does not survive a copy/paste of post content because the layout data lives in post meta.

PathWhat it does
wp-content/themes/supplychainschool/inc/multisite-functions.php:17Registers “Copy to {site}” bulk actions on the Pages list
wp-content/themes/supplychainschool/inc/multisite-functions.php:40stq_bulk_action_multisite_handler() — the page copier
wp-content/themes/supplychainschool/inc/multisite-functions.php:284get_images_from_blocks() — finds images inside blocks
wp-content/themes/supplychainschool/inc/multisite-functions.php:337stq_copy_attachment_to_blog()
wp-content/themes/supplychainschool/inc/multisite-functions.php:394Registers “Copy to {site}” bulk actions on the Media library
wp-content/themes/supplychainschool/inc/multisite-functions.php:2Makes the parent theme’s acf-json load in child themes

Also relevant: the multisite-clone-duplicator and post-duplicator plugins are installed for whole-site and single-post duplication.

For each selected page, per target blog (multisite-functions.php:51):

  1. Read the source post and parse_blocks() its content.
  2. Read the ACF flexible content by field key: get_field('field_5c40bad9dd95f', $post_id) (:57) — a hard-coded key.
  3. Read the raw post_content straight from the database ($wpdb->get_var(...), :81) as well as through get_post().
  4. Collect images referenced inside blocks via get_images_from_blocks().
  5. Collect the thumbnail and all other post meta.
  6. switch_to_blog($blog_id).
  7. acf_parse_save_blocks() the content, then wp_insert_post().
  8. Overwrite post_content directly in the database with the raw source value ($wpdb->update, :133) — the comment at :126 explains that inserting blocks with HTML content otherwise gets escaped.
  9. Sideload each referenced image with wp_upload_bits() + wp_insert_attachment() + wp_generate_attachment_metadata(), and str_replace() the old attachment id for the new one in the content.
  10. Restore the thumbnail: reuse an existing attachment with the same post_name if one exists, else sideload it.
  11. Copy every other post meta key verbatim, then update_field('flexible_content', $acf_content, $inserted_post_id).
  12. restore_current_blog().

An admin notice reports the count (:259).

stq_copy_attachment_to_blog() (:337) is simpler: read the original file path (preferring wp_get_original_image_path()), switch blog, pick a unique filename, copy() the file into the target uploads directory, insert the attachment and regenerate metadata.

The Media bulk action is only registered on blog 1 — the guard at :397 returns early when get_current_blog_id() > 1, so you can copy media from the UK site only.

get_images_from_blocks() (:284) walks parsed blocks and looks at $block['attrs']['data'] for exactly five keys:

background_image, image, images, profile_image, featured_image

Values may be a single id or an array. Anything else is invisible to the copier.

  • Multisite must be enabled (MULTISITE, SUBDOMAIN_INSTALL in wp-config.php).
  • Site list is capped at 'number' => 50 in both bulk-action registrations (:25, :404).
  • ACF Pro required — acf_parse_save_blocks() and update_field() are ACF functions.
  • The ACF field key field_5c40bad9dd95f is hard-coded (:57). If the flexible content field is ever recreated it gets a new key and the copier silently copies no flexible content — the page arrives empty of blocks while looking like it succeeded.
  • get_images_from_blocks() reads $block['attrs']['data'] without checking it exists (:297). A core Gutenberg block, or any block without ACF data, raises a PHP notice/warning on every copy.
  • Only five image field names are recognised. A new ACF image field with any other name copies as a stale attachment id pointing at the source blog’s media library — the image renders on the source site and breaks on the target. This is the most likely cause of “the copy is missing images”.
  • The id replacement operates on $item->post_content and assigns to $post_data['post_content'] (:161), but step 8 has already written the raw content directly to the database and $post_data is not re-saved afterwards. The remapped ids are computed and then discarded, so block image references keep the source blog’s ids.
  • Every other post meta is copied verbatim (:232), including any meta holding attachment ids or absolute source-site URLs.
  • $post_id is interpolated straight into SQL at :81. It comes from WordPress’s own bulk-action $object_ids, so in practice it is an int, but it is not prepared.
  • The bulk action label says “Copy”, and it is a copy — despite the action name prefix move_to_, the function name stq_multisite_move_media, the error_log('Moving media to blog ID: …') at :426, and the admin notice reading “%d posts have been moved” (:271). Nothing is deleted from the source.
  • The media notice hard-codes the destination name as “Store” (:444) — copied from another project.
  • No capability check beyond WordPress’s own bulk-action handling, and no confirmation step. Selecting 50 pages and picking a target starts 50 copies with image sideloading inline in one request.
  • Adding an image field to a block? Add its field name to $keys_to_check in get_images_from_blocks() (:299) in the same commit, or media will not follow the copy. This is the single most important coupling in this feature — see [[acf-flexible-content-blocks]].
  • If the flexible content field is rebuilt, update the hard-coded key at :57. Better: replace it with the field name ('flexible_content'), which is what the write side already uses at :240.
  • New post types to copy: the bulk action is registered against bulk_actions-edit-page only. Register bulk_actions-edit-{type} and reuse the same handler — it already branches on post_type != 'attachment'.
  • Verify by hand on a staging network: copy one page with a hero background image, a gallery and a featured image, then check on the target site that all three render and that the attachment ids in post_content point at the target blog’s media library.
  • Deliberately not abstracted: the direct $wpdb->update() of post_content. wp_insert_post() escapes serialised block markup, which breaks ACF blocks; the comment at :126 records this.

None. This feature writes across blogs and has no dry-run mode — test on staging only.

Related: [[acf-flexible-content-blocks]], [[site-chrome-and-navigation]].