wordpress/wordpress-importer
Official WordPress plugin for importing content from a WXR export file. Migrates posts, pages, comments, custom fields, categories, tags, and media attachments, making it easy to move or merge content between WordPress sites.
Reality check first: wordpress/wordpress-importer is not a Laravel package — it is the official WordPress Importer plugin. There is no service provider, no config file, no facade, and no namespaced autoloading. Everything lives in global scope (WP_Import, WXR_Parser) and calls WordPress core functions (wp_insert_post(), wp_set_object_terms(), media_handle_sideload()). Deciding how you host it is the whole design decision.
Three viable modes, pick one before writing code:
requires wp-load.php and calls WP_Import directly. Fast to build, painful to operate.WXR_Parser to read the XML, then map to your own Eloquent models. Usually the right answer if the content isn't staying in WordPress.Minimal steps:
# as a plugin (composer/installers with type=wordpress-plugin, or just drop it in)
composer require wordpress/wordpress-importer
# or: git clone into wp-content/plugins/wordpress-importer
wp plugin activate wordpress-importer
First use case: export from the legacy site (Tools → Export → All content), then import into staging and diff posts/media/menus:
wp import legacy-export.xml --authors=create --path=/var/www/wordpress
Where to look first: the plugin bootstrap (wordpress-importer.php, which registers the importer and loads the classes) and class-wp-import.php — that file is the product. Read import() top to bottom; it is the entire pipeline: import_start() → process_terms() → process_posts() → backfill_parents() → backfill_attachment_urls() → import_end(). The class-wxr-parser*.php files are the interchangeable XML readers (SimpleXML → XMLReader/XML → regex fallback for malformed exports).
Best isolation. No WordPress globals leak into your app, no second DB connection, and a hung import kills a child process instead of your worker.
// app/Jobs/ImportWordPressExport.php
public function handle(): void
{
$result = Process::path(config('wp.path'))
->timeout(1800)
->run([
'wp', 'import', $this->xmlPath,
'--authors=create',
'--skip=image_resize', // check `wp help import` for your WP-CLI version
'--path=' . config('wp.path'),
]);
$result->throw();
SearchReplaceJob::dispatch($this->sourceUrl, $this->targetUrl);
}
Follow with wp search-replace 'https://old.tld' 'https://new.tld' --all-tables — the importer rewrites attachment URLs in post bodies but not hardcoded links in options, widgets, or serialized builder data.
define('WP_LOAD_IMPORTERS', true);
define('WP_IMPORTING', true);
require config('wp.path') . '/wp-load.php';
require_once ABSPATH . 'wp-admin/includes/import.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$importer = new WP_Import();
$importer->fetch_attachments = false; // do media out-of-band
$importer->import($absoluteXmlPath);
Run this only from an Artisan command in a dedicated queue, never from a web request. Verify import() exists in your pinned version — the 0.8 rewrite reworked the UI layer and method surface.
WXR_Parser is the least WordPress-coupled class in the package, but it returns WP_Error, so you need brain/monkey or a tiny shim. Then map into your own models:
$parser = new WXR_Parser();
$data = $parser->parse($path); // ['posts' => [...], 'categories' => [...], ...]
foreach ($data['posts'] as $post) {
if ($post['post_type'] !== 'post' || $post['status'] !== 'publish') continue;
Article::updateOrCreate(
['legacy_id' => $post['post_id']],
[
'title' => $post['post_title'],
'slug' => $post['post_name'],
'body' => $post['post_content'],
'published_at' => $post['post_date'],
'meta' => collect($post['postmeta'])->pluck('value', 'key')->all(),
],
);
}
This gives you full control over media, taxonomy mapping, and re-runs — at the cost of reimplementing attachment and menu logic.
The importer exposes filters that solve most "it did the wrong thing" problems:
// Idempotency: match on a legacy ID stored in post meta instead of title+date+content
add_filter('wp_import_existing_post', function ($post_id, $post) {
$found = get_posts([
'meta_key' => '_legacy_wxr_id', 'meta_value' => $post['post_id'],
'post_type' => $post['post_type'], 'fields' => 'ids', 'limit' => 1,
]);
return $found[0] ?? 0;
}, 10, 2);
// Tag every imported post so you can find/roll back later
add_filter('wp_import_post_data_processed', function ($postdata, $post) {
$postdata['meta_input']['_legacy_wxr_id'] = $post['post_id'];
$postdata['meta_input']['_import_batch'] = getenv('IMPORT_BATCH');
return $postdata;
}, 10, 2);
add_filter('wp_import_post_meta', fn ($meta, $post_id, $post) => array_filter(
$meta, fn ($m) => ! str_starts_with($m['key'], '_oembed_')
), 10, 3);
import_start / import_end are your timing and logging hooks; wp_import_post_terms and wp_import_terms cover taxonomy remapping.
Whatever mode you choose, persist source_id → local_id (and source_url → attachment_id) in a table. It makes re-runs idempotent, lets you resume after a timeout, and is the only reliable way to fix up relationships inside serialized meta later.
new WP_Import() work. Don't require it inside a Laravel request — WordPress calls exit(), installs its own error handler, and opens a second MySQL connection to the same database (deadlock risk with your open transactions).WXR_Parser_SimpleXML loads the full document; large exports blow memory_limit or hit libxml limits. Split exports by date range/author (WP-CLI wp export --post_type=post --start_date=…) and run several imports rather than raising memory.fetch_attachments = true downloads every remote file mid-import — slow, timeout-prone, and it silently skips files behind hotlink protection or expired URLs. Prefer fetch_attachments = false, then sync media separately (or wp media import) and rewrite URLs afterwards.post_exists() matches on title + date + content, so a re-import of edited content creates duplicates while a new post that happens to collide gets skipped. Always override with wp_import_existing_post._elementor_data, ACF relationship fields, and page-builder JSON keep the old post/attachment IDs. Backfill only handles attachment URLs and menu parents. Plan a post-import fixup pass keyed off your mapping table.WP_IMPORTING is defined during import. Plugins that check it (notification emails, cache warmers, indexers) will go quiet — useful, but it also means your post-import verification can see stale state until you flush caches.unfiltered_html capability and content filters can alter markup, and imported shortcodes/blocks execute on render. Treat any WXR from an untrusted source as untrusted PHP/HTML input; validate the XML source and disable external entity loading in your PHP config.How can I help you explore Laravel packages today?