Beschreibung
Import an export into a new WordPress site and two things break that nothing in the box will fix for you.
ACF Relationship, Post Object and Gallery fields stop working. They store post IDs, and the new site assigns different IDs, so every one of those fields now points at the wrong post or at nothing at all. Nothing reports it. The fields simply look empty, or worse, quietly reference unrelated content — and on a site built around related posts, professionals, case studies or products, that is most of the site.
Every image still lives on the old server. The core importer copies the references, not the files. The content looks fine right up to the day the source site is switched off.
Migravo fixes both, on a site that has already been imported or as part of importing it.
Repairing relationships
Upload the export from the source site on the ID Map tab. Migravo matches each old post to its new one by slug, builds a map of old ID to new ID, then rewrites every relationship, post-object and gallery field on the site in one pass.
It sweeps the whole database rather than one post type, so both sides of a two-way relationship are repaired as long as both are present — the field on the Artist and the field on the Song are just two more fields to fix, and it does not matter which order you imported them in. Import one post type now and the other later, then run the repair again: the map keeps what it already knows and grows as you import.
It only rewrites values it can account for. A flat list of numeric IDs is remapped; an ACF Link field, a repeater row, a phone number that merely starts with digits, and any ID with no entry in the map are all left exactly as they were. An ID whose target has since been deleted is reported rather than written as a dead reference.
And it tells you what it could not fix, as a verdict rather than a guess. An ID it has no mapping for is left alone — correct in itself, but the field still points somewhere wrong and the number still looks like a valid ID, so nothing errors. Migravo checks each one against the field’s own definition: ACF records which post types a Relationship or Post Object field may reference, so a practice_areas field declared as accepting service while holding an ID that is a trade_report here is broken as a matter of fact. Those are reported as broken and listed first, with how many fields hold each ID and what it actually is. Values that match the type the field accepts are marked as almost certainly already repaired, so re-running the tool does not raise false alarms. Import the missing post type and run it again; the map grows as you import, so order does not matter.
Re-hosting media
During import, Migravo finds every media URL in the post content and the featured image, downloads each file from the source site into the Media Library, and rewrites the content to point at the local copy. Files are deduplicated by source URL and filename, so importing the same export twice does not produce a second copy of every image.
What else the export carries, and what arrives with it
A WXR file says more about each post than its title and content, and a field that is read by nothing is a field that goes missing without a word. These now come across:
- Page hierarchy.
wp:post_parentholds an ID from the source site, so it means nothing here until it is translated — and an untranslated one is why imported trees arrive flat, every child at the top level. Order in the file does not matter: a child that arrives before its parent is placed when the parent turns up. - Image alt text. Written on the attachment item in the export, which is not a post that gets imported, so it was being left behind for every image at once. Alt text already on this site is never overwritten.
- Comment and ping status, rather than falling back to whatever this site’s defaults happen to be.
- Post password. A protected post used to arrive public, with nothing to say so.
- Sticky posts, for
post— the one type WordPress reads that list for. - The dates, all four of them. Publish and last-modified, local and UTC. WordPress will fill these in itself if nothing supplies them, and what it fills in is wrong: last-modified becomes the time the import ran, and the UTC columns are recalculated from the destination’s timezone, so moving a site between two zones shifts every one of them. A draft keeps its publish date on a re-import too, which it did not before.
Each is applied only where the export actually states it. An empty tag is a statement — „no password“, „not sticky“ — and is honoured on a re-import; a tag that is not in the file at all says nothing, and nothing on this site is overwritten on the strength of it.
One limitation worth knowing. Posts are tied back to the source by the ID the export gives them, and that ID is not qualified by which site it came from. Import two different sites into one destination and a post from each can claim the same source ID; the hierarchy lookup then has two candidates and takes the most recent. This is inherent in what a WXR file records, not something the plugin can detect, and it applies equally to the relationship repair.
The rest of a real migration
- Resumable, non-blocking import — preview what the file contains, then import with a live progress bar and 1–10 concurrent requests. Close the tab or press Stop and the import offers to continue where it left off. Posts lost to a server hiccup are retried automatically, and any that still fail are listed with a button to retry just those.
- Safe re-imports — choose to skip, update in place, or delete and replace posts that already exist. Matching accounts for the fact that WordPress rewrites slugs on insert, so re-running an import does not silently duplicate posts.
- Taxonomy mapping — send a taxonomy from the export into a different taxonomy on this site, or skip it.
- Author mapping — map each author in the export to an existing user, or create one.
- Field renaming — map meta/ACF field keys when the source and destination names differ.
- Featured image from a custom field — for source sites that store it outside the standard thumbnail meta.
- Multisite aware — choose which site in the network to import into, or to build the ID map against.
- Built for large exports — tested against a 52 MB export of 5,179 posts, which it parses in about 44 seconds using around 4 MB of memory.
Security
- Every AJAX action requires a valid nonce and the
importcapability. - All database queries are parameterized via
$wpdb->prepare(). - Media downloads are guarded against SSRF — loopback, private-network and link-local addresses (including the cloud metadata endpoint at 169.254.169.254) are rejected. Redirects are followed, and every hop is checked by the same rule as the first, so a public URL that redirects to an internal address is refused at the redirect rather than fetched. A source site on a private network can be allowed deliberately, per host, using the
wxmi_allow_private_media_hostfilter — see the FAQ. - Restoring serialized meta values blocks PHP object injection (unserialize is called with
allowed_classes => false). - The temporary directory used during import is given a random, unguessable name, so the export and the per-post cache written inside it cannot be requested by anyone who does not already know the path. An .htaccess and an index.php are written alongside as a second layer — they help on Apache, but they are not relied on, because nginx, LiteSpeed and IIS do not read .htaccess at all.
Why not just use the core WordPress Importer?
Use it — and then use this. The core importer creates the posts, which is the part that already works. What it has no concept of is what became of everything that referenced those posts by ID, or of the files they pointed at:
- It does not know that ACF Relationship, Post Object and Gallery fields hold post IDs, so it leaves every one of them pointing at whatever now happens to own that ID on the new site.
- It copies image references, not images. The content depends on the old server staying online forever.
- Re-running it creates another copy of everything, so a partial or interrupted import cannot simply be repeated.
Is this a full-site migration tool?
No, and deliberately not. If you can copy an entire site — database, uploads and all — a tool built for that is the right choice and this is not it.
Migravo is for the cases where you cannot: you have a WXR export and a destination that already exists, you are pulling a few post types across rather than cloning a whole site, or you are on the other side of an import that already happened and left broken relationships behind. The ID Map tab works on a site that was imported months ago by something else entirely.
Two things in a WXR file are deliberately not imported, and are not planned:
- Comments. Migravo is about posts, the fields that point between them and the media they use. Comment threads, their authors and their moderation state are a separate body of data with their own spam and approval concerns, and the core WordPress Importer already handles them.
- Navigation menus. A menu is a set of posts referring to other posts by ID, so importing one without also owning the pages it points at produces a menu of dead links. Rebuild menus on the destination once the content is in place.
Run the core importer for either of those, then use this for the parts it leaves behind.
Installation
- Upload the plugin folder to
/wp-content/plugins/. - Activate the plugin through the „Plugins“ menu in WordPress.
- Go to Tools Migravo Migration.
FAQ
-
My ACF Relationship fields are empty after importing — can this fix it?
-
Yes, and that is the main reason the plugin exists. Those fields store post IDs, and the new site handed out different IDs, so the values that survived the import now point at nothing — or at whatever unrelated post happens to hold that ID.
Open the ID Map tab and upload the export from the source site. Migravo matches every old post to its new one by slug, then rewrites every Relationship, Post Object and Gallery field on the site to the correct new IDs — every post type at once, so both directions of a two-way relationship are fixed provided both sides are on the site.
-
Can I use it on a site that was already imported by something else?
-
Yes. The ID Map tab needs only the original export file and the site as it stands now; it does not care what performed the import or how long ago. If a migration left broken relationships behind months ago, this repairs them without re-importing anything.
-
Which field types get remapped, and which are left alone?
-
Remapped: flat lists of post IDs, which is how ACF stores Relationship, Post Object, Gallery and similar fields.
Left untouched: anything that is not a plain list of IDs. An ACF Link field (title/url/target), repeater rows, a value that merely starts with digits such as a phone number, and any ID with no entry in the map are all written back exactly as they were. An ID whose target has since been deleted or trashed is counted and reported rather than written as a dead reference.
-
What XML format does this accept?
-
Standard WordPress eXtended RSS (WXR) — the same file produced by any WordPress site’s Tools Export.
-
Does it download media from the old site automatically?
-
Yes, when „Download Media“ is checked. It scans the post content and featured image for URLs on the source site’s domain, downloads each into the Media Library, and — if „Update URLs in Content“ is also checked — rewrites the content to point at the new local URLs.
-
What happens if a post already exists?
-
You choose per import: skip it, update its content and media in place, or replace it (delete the existing post and re-import fresh). Matching is by title + slug + post type.
-
No media was imported from my staging site — why?
-
Almost certainly because that site is on a private network. Staging and development sites usually resolve to a private address (10.x, 172.16–31.x, 192.168.x), and the plugin refuses to fetch from those by default: an import file comes from outside your site, and blindly following the URLs inside it is how a server ends up reading its own internal services.
If you know the source site and want to allow it, name that one host:
add_filter( 'wxmi_allow_private_media_host', function ( $allow, $host ) { return 'staging.example.com' === $host ? true : $allow; }, 10, 2 );Only the host you name is allowed. Loopback, link-local and every other private host stay blocked, and there is no setting in the admin screen to switch this on — it takes deliberate code on your own site.
-
Does it work on Multisite?
-
Yes — a site selector appears wherever it’s relevant (import, ID map, ACF field lookup) so you can target any site in the network without switching your admin session.
-
What if a scheduled cleanup is needed?
-
An hourly cron job (
wxmi_cleanup_temp_files) removes temporary uploaded XML/cache files older than one hour, in case an import session is abandoned partway through.
Rezensionen
Zu diesem Plugin liegen noch keine Rezensionen vor.
Mitwirkende und Entwickler
„Migravo WXR Media Migration“ ist Open-Source-Software. Folgende Menschen haben an diesem Plugin mitgewirkt:
MitwirkendeÜbersetze „Migravo WXR Media Migration“ in deine Sprache.
Interessiert an der Entwicklung?
Durchstöbere den Code, sieh dir das SVN-Repository an oder abonniere das Entwicklungsprotokoll per RSS.
Änderungsprotokoll
1.2.2
-
A relationship pointing at a post you have since trashed is now reported as that, instead
of as definitely broken. The two need opposite responses — one says import the post type
that is missing, the other says restore something from the trash — so naming the second as
the first is worse than saying nothing.The „Skipped N stale/trashed reference(s)“ line existed for this and could never appear.
It fires when the ID map holds an entry whose target is trashed, but the map is built from
a query that excludes trashed posts, so the value fell through to the unresolved branch
instead. There it was judged by what post currently holds that ID on this site — nothing,
since the post was trashed rather than replaced — and „points at nothing“ reads as broken.Reproduced before the change: a post imported and then trashed gave skipped=0 and was
counted among the definitely-broken. Afterwards, the same script on the same fixture gives
skipped=1, one fewer definitely-broken, and no entry in the broken-reference list. What is
stored does not change either way; the value was already left alone, and only the report
was wrong.Costs one query per run, not one per value.
1.2.1
Dates. Three columns were being filled in by WordPress rather than read from the export,
and each was wrong in its own way.
- post_modified is now what the source says. It cannot be passed to wp_insert_post() –
that function reads it from nowhere and decides for itself, writing the current time on an
update and copying post_date on an insert – so it is written to the column afterwards,
which is the only way there is. Anything ordered by „recently modified“ therefore described
when the import ran, or repeated the publish date. Measured on a post last edited
2026-05-06 and published 2019-01-02: it arrived dated 2019-01-02. - post_date_gmt is taken from the export instead of being recomputed. Left out, WordPress
derives it from post_date using the DESTINATION’s timezone – so migrating between two zones
shifted every post’s UTC time by the difference while the local time looked untouched.
Measured importing a UTC export into a UTC+7 site: every GMT column out by exactly seven
hours. Feeds, the REST API and anything scheduling from GMT read that column. - A draft no longer has its publish date reset when it is re-imported. wp_update_post()
moves post_date to the current time for any draft whose GMT date is the unfixed-time
sentinel, which is exactly what a WXR file holds for a draft. The rule exists so that saving
a draft does not backdate it; an import is the opposite case, and edit_date is what tells
core the date was chosen deliberately. This one predates 1.2.0 – it needs a draft AND a
re-import to show at all, which is why it went unnoticed.
Checked against WXR generated by WordPress itself, on a first import and on a re-import, with
the destination in a different timezone from the export so that leaving a field out could not
accidentally produce the right answer. 16 comparisons across 4 posts: before, 10 wrong; after,
0.
1.2.0
Five fields the export carries were read by nothing, so an import produced a post that only
partly resembled the source. Each was checked against WXR generated by WordPress itself
rather than a file written by hand, and against real exports, on a first import and on a
re-import.
- Page hierarchy is rebuilt instead of flattened.
wp:post_parentholds an ID belonging to
the source site and was not being read at all, so every tree arrived flat — each child at
the top level with nothing to say it had ever had a parent. On one real 153-item export
that is 59 relationships lost in silence. Order in the file does not matter: a child that
arrives before its parent is placed when the parent turns up, which is not a hypothetical —
3 of those 59 appear before the parent they name. Verified withget_post_ancestors()and
get_pages() on a three-level page tree, imported parent-first and child-first; before
this, both came out with no ancestors at all and no children under the root. - Loops are refused. WordPress does check for them, but its check begins „new post can’t
cause a loop“ and returns — so a post created against a chain that is already looped is
attached to it, and everything that walks ancestors then follows a line that never ends.
Reproduced by looping two pages by hand and importing a third naming one of them. - Image alt text comes across.
_wp_attachment_image_altappeared nowhere in the plugin, so
every image arrived blank and the accessibility work done on the source site was lost in
the move. It is written on the attachment item, which is not a post that gets imported, so
it is collected during the pass that already reads those items. Alt text on this site is
never overwritten — the export is authoritative about what the source said, not about what
the destination has since decided — and images already in the library pick it up without
being fetched again. - Post password is imported. A protected post arrived public, with nothing to indicate that
anything had been dropped. - Comment status, ping status and sticky are imported, instead of falling back to this
site’s defaults. Comment and ping are taken only when they readopenorclosed;
comments_open() tests for the exact string, so any other value would behave as closed
while claiming to be something else. - An empty tag and a missing tag are told apart, which only shows on a re-import. Empty is
the export saying „no password“, „not sticky“, and is applied; a tag absent from the file
says nothing, and nothing here is overwritten on the strength of it. Real exports exist
that omit these fields entirely — two items from one were imported, given a password by
hand, and re-imported, and the password was gone. - Importing an export twice under different titles no longer leaves the second hierarchy in
halves. Where two posts on this site both claim the same source ID, the most recent is
taken, and a child that had to settle for an older copy is moved when its real parent
arrives — scoped to the import in progress, so the earlier tree is not pulled apart to
build the new one.
1.1.3
- An existing image is now identified by where it sits, not by what it is called.
Matching was on the bare filename, and WordPress only keeps filenames unique inside one
month folder — so a site with any history has /2024/01/logo.png alongside
/2025/06/logo.png, and logo.png, banner.jpg and hero.jpg are exactly the names that
repeat. Reproduced with two such images: asking for the 2025 one returned the 2024 one,
and the right one was then never downloaded, because the import believed it already had
it. The wrong picture, with nothing reported. Matching is now on the upload path —
2025/06/logo.png — compared for equality against what WordPress records for each file.
This is the case the plugin is written for, a destination that already holds media, and
not an edge case. - A match found that way is no longer written back as if it were fact. The old code
stamped its guess onto the attachment, so the next lookup accepted it without
re-examining and a wrong match could never correct itself. That record is now written in
one place only, after a file has genuinely been downloaded from that URL. - When media download is switched off, the filename is still used, but only after the path
has been tried and found nothing. In that mode the user has said the files are already
on the site, so a missed match means no image at all rather than a re-download — an
imperfect match beats a missing one there, while a right answer still wins wherever both
are available. - Removed the Screenshots section from this file. It described two screenshots that were
not shipped. - Post content containing „]]>“ is no longer mangled. A CDATA section cannot hold that
sequence, so an exporter meeting one has to break out and back in — WordPress writes
„]]]]><