July 23, 2026 · Tommy Bordas · updated on September 1, 2026

Miroir Local Sync, my open source WordPress plugin to sync local and production

wordpressphppluginmigrationopen-source

I just published Miroir Local Sync, an open source WordPress plugin that syncs a local database and a production one in both directions: push, pull, serialized data replacement, safety snapshots, REST or SFTP transport. Here is the need that pushed me to build it, and the technique behind it.

The problem, the round trip between local and production

On WordPress, the moment you work seriously, you have two worlds. The local one, where you build a theme, test a plugin, break things at no risk. And production, where the real content, the real orders, the real users live. Moving a database between these two worlds is surprisingly painful.

The usual approaches each have their flaw. A raw SQL export forces you to hand edit URLs with search and replace, and corrupts serialized data along the way. All or nothing migration plugins overwrite the target without warning, which is perfect to clone an empty site and catastrophic to bring back only the editorial content. As for command line tools, they assume you are comfortable in a terminal.

My goal was different: a tool that a non technical person could use from the WordPress admin, without dreading wiping production by mistake. A mirror, not a bulldozer.

What Miroir Local Sync does

The plugin offers a deliberately tight set of functions:

  • Push and Pull of the database, from local to production and back.
  • Smart text replacement that understands PHP serialized data.
  • Automatic snapshots and restore before every import.
  • Detailed comparison before applying, with data loss warnings.
  • Targeted deployment of a specific theme or plugin.
  • Editorial content merge by post type, instead of a global overwrite.
  • Transport of your choice, over the REST API or over SFTP.

The rest of this article dwells on the three points that took the most care, because they are also the ones that do the most damage when neglected.

Pitfall number one, serialized data

This is the classic that turns a migration into a lost afternoon. WordPress stores a lot of settings as serialized PHP arrays: theme options, widget settings, post metadata. Serialization encodes the byte length of each string. A minimal example:

// An array holding a local URL
$data = ['url' => 'http://localhost:8080'];

serialize($data);
// a:1:{s:3:"url";s:21:"http://localhost:8080";}
//                    ^^^^ 21 = exact length of the string

The s:21 declares that the string is twenty one bytes long. If you naively replace http://localhost:8080 with https://tommy-bordas.fr, the new string is twenty three bytes, but the counter still reads 21. As a result, PHP can no longer unserialize, and the setting is silently lost.

// Naive search and replace, the length is now wrong
str_replace('http://localhost:8080', 'https://tommy-bordas.fr', $serialized);
// a:1:{s:3:"url";s:21:"https://tommy-bordas.fr";}
//                    ^^^^ still 21, unserialize is broken

The right method is to walk the structure rather than the raw string: deep unserialize, apply the replacement on every string leaf, then re-serialize and let PHP recompute the lengths. The plugin also handles the awkward cases, nested arrays, objects, data already corrupted by a previous clumsy search and replace.

function replace_recursive($value, $search, $replace) {
    if (is_string($value)) {
        return str_replace($search, $replace, $value);
    }
    if (is_array($value)) {
        return array_map(
            fn($v) => replace_recursive($v, $search, $replace),
            $value
        );
    }
    return $value; // integers, booleans, null, left untouched
}

// Unserialize, replace inside the structure, re-serialize
$clean = serialize(
    replace_recursive(unserialize($serialized), $search, $replace)
);

That step, invisible to the user, is what separates a clean imported database from one full of ghost settings.

Never import without a net, snapshots and comparison

A sync that overwrites data is a dangerous operation by nature. The principle I followed: nothing irreversible without a way back.

Before every import, the plugin takes a snapshot of the current state. If the import goes wrong, or if you realize afterwards you got the direction wrong, the restore brings the database back to its previous state. The gain in confidence is huge: you dare to run a sync because you know you can undo it.

Second guardrail, the comparison before applying. Rather than applying blindly, the tool shows what is about to change and flags the potential losses: posts present in production but absent locally, settings that are about to be replaced. The user confirms with full knowledge, instead of discovering the damage afterwards.

Two transports, REST and SFTP

Connecting two WordPress sites requires a channel. I kept two options, because none fits every host.

The REST API is the simplest path: the plugin talks site to site over HTTP, with authentication, without installing anything else. It is enough in most cases and stays easy to debug.

SFTP answers the more locked down environments, where direct exchanges between sites are blocked, or when you prefer to move a file rather than an HTTP stream. It is the robust fallback when the network refuses to cooperate.

Leaving the choice avoids the trap of a tool that works in the demo and fails on the client's real host.

Merge content, do not just overwrite it

The feature I am most pleased with, because it steps outside the usual migration tool pattern. Instead of replacing everything, you can merge editorial content by post type. Bring back only the posts, or only the products, without touching the rest of the database.

This is the real day to day scenario: content lives in production, where editors work, but development happens locally. You want to pull the new posts to test a layout, without wiping the theme settings of an ongoing redesign. Targeted merge makes that back and forth possible without breaking everything.

Since 1.0, protecting live production data

Version 0.5 did its job on a brochure site. On a shop, one blind spot remained, one I knew all too well: pushing my local database to production wiped the orders that had come in while I was developing. That is the structural flaw of every migration tool, they replace the whole database or nothing.

Version 1.0 therefore adds a checkbox to the push wizard, ticked by default as soon as WooCommerce is present: protect live data on the target. The tables involved are excluded from the local export, so they are never transferred, never imported, never rewritten. Production keeps its own state.

// Table patterns preserved during a protected push
$prefix . 'wc_order'               // HPOS orders, addresses, stats
$prefix . 'woocommerce_order_item' // order line items
$prefix . 'woocommerce_sessions'   // active carts
$prefix . 'wc_customer_lookup'     // customers
$prefix . 'wc_reserved_stock'      // stock held by pending orders
$prefix . 'users'                  // accounts created since go live
$prefix . 'usermeta'
$prefix . 'comment'                // order notes and product reviews

One limit worth stating plainly: if the shop still stores its orders in wp_posts, that is legacy mode with HPOS disabled, those particular rows cannot be isolated at table level. The wizard says so explicitly rather than implying full protection.

On my own shop, over a thousand products and close to four thousand orders, that checkbox changes what the tool is. It goes from a cloning plugin to a deployment plugin, usable on a site that takes money while you work.

Automation and robustness, what 1.1 brings

Three more efforts followed, each born from a concrete annoyance.

Scheduled syncs. A nightly pull that refreshes the local copy from production, without thinking about it. Frequency of your choice, an anti overlap lock so two runs never collide, notification by email or webhook. One click deployment shortcuts can now run on their own too.

WP-CLI commands. wp miroir status, compare, push and pull, with the same options as the interface. Enough to wire a sync into a continuous integration pipeline, or into a real system cron, far more reliable than WP-Cron on large volumes.

# Refresh the local copy from production every night
0 3 * * * cd /path/to/site && wp miroir pull --profile=prod --db --yes

Background execution. A long sync used to live inside a single HTTP request, which shared hosting proxies happily cut after sixty seconds. The operation now runs in a detached server request, on the WP-Cron model, while the browser only polls for progress. You can close the tab, the work carries on. If the host blocks loopback requests, the plugin falls back to the previous synchronous mode on its own.

Smaller things matter just as much in daily use: comparison caches file fingerprints and becomes near instant on the second run, a button releases the remote agent lock after an interrupted operation, and the activity log exports to CSV.

Eighteen releases later, what real use corrected

Since 1.1 the plugin has gone through eighteen patch releases. Nearly all of them came from problems met on real sites, often my own, rather than from features planned in advance. Here are the ones worth knowing about before installing it.

A data leak I had not seen coming. This is the most important fix of the series, in 1.1.10. The plugin working folder, the one that receives snapshots and database exports, sat inside the media tree. A media push therefore carried it along with everything else, which could drop a full dump of the local database on the target, password hashes and email addresses included. The folder is now excluded from those transfers. If you pushed media with an earlier version, go and look at what that folder holds on the target.

The same release widens search replace to every way a URL can be written: http and https, protocol relative links such as //example.com, and the variants with or without www. Missing a single one of them is enough to leave broken images behind after a migration.

Live data protection went further than planned. The checkbox described above has been ticked by default since 1.1.8, including for scheduled syncs and WP-CLI commands, which still followed their own logic until then. Version 1.1.9 handles the case I had not anticipated: when a push fails and triggers a rollback, restoring the snapshot used to overwrite the orders that had arrived during the operation. The rollback is now selective and leaves protected tables alone. Version 1.1.11 extends that table list to download permissions, payment tokens and the Action Scheduler queue, and makes the whole thing work even when the two sites do not share the same table prefix.

Imports that fail loudly. Several releases dealt with what happens when something goes wrong halfway through an import. Version 1.1.4 reconciles table prefixes between the two sites, uses the right character set for both dump and import, and makes the PHP fallback export fail instead of quietly producing a truncated file. An SQL error hit mid dump is no longer replayed. Version 1.1.6 fixes data corruption caused by percent signs in that same fallback export, with a repair path for values already damaged.

Content sync became differential. Until 1.1.14, merging a content type transferred all of its items, which gets slow once you have a few thousand posts. Only items that are missing or older than the source now travel. Version 1.1.17 lets you tick several types at once in the comparison screen, processed one after the other with per type progress, and 1.1.16 makes sure you always get an explicit answer at the end, including when there was nothing to transfer.

Seeing what happens during a long operation. Versions 1.1.2, 1.1.3 and 1.1.18 mostly went into making the wait legible: a progress screen with elapsed time and readable step labels, a byte counter during the compression and upload phases, and the end of frozen buttons while the remote agent updates. These are interface details, and they matter mostly on an operation that runs for several minutes.

The rest covers daily work across several sites. A reconnect button lets you re pair a site without deleting its profile, so without losing the deployment shortcuts attached to it, after an application password expires. The comparison screen shows editorial activity over the last seven days on the target, which saves you from overwriting a post published yesterday. Themes and plugins present on the target but absent locally can be deleted from the comparison, with active ones refused as a guardrail. And the agent keeps the identity that authorized it through an import, flushing the object cache behind it, which avoids locking yourself out.

A young project, open to feedback

On maturity, Miroir Local Sync is at version 1.1.18, tested up to WordPress 7.1 and published on the official repository after review by the wordpress.org team. It needs WordPress 6.0 and PHP 8.1 as a minimum, and the install count remains modest. I grow it along with my own needs and the feedback that comes in. The replacement engine, the riskiest part since it rewrites a whole database, is covered by a test suite anyone can run from the repository. I share it because it already solves a real problem for me, and because publishing it remains the best way to make it sturdier.

If you too juggle between local and production on WordPress, the plugin is available and documented on the official repository: Miroir Local Sync on wordpress.org. Feedback, bug reports and ideas are all welcome.

This work extends a line of thought I explore on the ERP side in Bidirectional ERP and website sync, the pitfalls of real-time connectors, where the same questions of idempotency and flow direction come back, at a different scale.