23 juillet 2026 · Tommy Bordas · mis à jour le 1 septembre 2026

Miroir Local Sync, mon plugin WordPress open source pour synchroniser local et production

wordpressphppluginmigrationopen-source

Je viens de publier Miroir Local Sync, un plugin WordPress open source qui synchronise une base locale et une production dans les deux sens : push, pull, remplacement des données sérialisées, instantanés de sécurité, transport REST ou SFTP. Retour sur le besoin qui m'y a poussé et sur la technique derrière.

Le problème, l'aller-retour entre local et production

Sur WordPress, dès qu'on travaille sérieusement, on a deux mondes. Le local, où l'on développe un thème, où l'on teste un plugin, où l'on casse des choses sans risque. Et la production, où vivent le vrai contenu, les vraies commandes, les vrais utilisateurs. Faire circuler une base de données entre ces deux mondes reste étonnamment pénible.

Les approches habituelles ont chacune leur défaut. L'export SQL brut oblige à jouer du chercher remplacer à la main sur les URL, et casse les données sérialisées au passage. Les plugins de migration « tout ou rien » écrasent la cible sans prévenir, ce qui est parfait pour cloner un site vierge et catastrophique pour rapatrier uniquement le contenu éditorial. Quant aux outils en ligne de commande, ils supposent qu'on est à l'aise avec un terminal.

Mon objectif était différent : un outil que quelqu'un de non technique puisse utiliser depuis l'administration WordPress, sans redouter d'effacer la production par mégarde. Un miroir, pas un rouleau compresseur.

Ce que fait Miroir Local Sync

Le plugin propose un jeu de fonctions volontairement resserré :

  • Push et Pull de la base, du local vers la production et l'inverse.
  • Remplacement de texte intelligent qui comprend les données sérialisées PHP.
  • Instantanés automatiques et restauration avant chaque importation.
  • Comparaison détaillée avant d'appliquer, avec des avertissements de perte de données.
  • Déploiement ciblé d'un thème ou d'une extension précise.
  • Fusion du contenu éditorial par type de publication, au lieu d'un écrasement global.
  • Transport au choix, via l'API REST ou via SFTP.

Le reste de cet article s'attarde sur les trois points qui ont demandé le plus de soin, parce qu'ils sont aussi ceux qui font le plus de dégâts quand on les néglige.

Le piège numéro un, les données sérialisées

C'est le classique qui transforme une migration en après midi perdu. WordPress stocke énormément de réglages sous forme de tableaux PHP sérialisés : options de thème, réglages de widgets, métadonnées d'articles. La sérialisation encode la longueur en octets de chaque chaîne. Un exemple minimal :

// Un tableau contenant une URL locale
$data = ['url' => 'http://localhost:8080'];

serialize($data);
// a:1:{s:3:"url";s:21:"http://localhost:8080";}
//                    ^^^^ 21 = longueur exacte de la chaîne

Le s:21 annonce que la chaîne fait vingt et un octets. Si l'on remplace bêtement http://localhost:8080 par https://tommy-bordas.fr, la nouvelle chaîne fait vingt trois octets, mais le compteur affiche toujours 21. Résultat, PHP n'arrive plus à désérialiser, et le réglage est silencieusement perdu.

// Chercher remplacer naïf, la longueur n'est plus juste
str_replace('http://localhost:8080', 'https://tommy-bordas.fr', $serialized);
// a:1:{s:3:"url";s:21:"https://tommy-bordas.fr";}
//                    ^^^^ toujours 21, désérialisation cassée

La bonne méthode consiste à parcourir la structure plutôt que la chaîne brute : désérialiser en profondeur, appliquer le remplacement sur chaque feuille de type chaîne, puis re-sérialiser en laissant PHP recalculer les longueurs. Le plugin gère aussi les cas tordus, tableaux imbriqués, objets, données déjà corrompues par un précédent chercher remplacer maladroit.

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; // entiers, booléens, null, on ne touche pas
}

// On désérialise, on remplace dans la structure, on re-sérialise
$clean = serialize(
    replace_recursive(unserialize($serialized), $search, $replace)
);

C'est cette étape, invisible pour l'utilisateur, qui fait la différence entre une base importée propre et une base pleine de réglages fantômes.

Ne jamais importer sans filet, instantanés et comparaison

Une synchronisation qui écrase des données est une opération dangereuse par nature. Le principe que j'ai suivi : rien d'irréversible sans point de retour.

Avant chaque importation, le plugin prend un instantané de l'état courant. Si l'import se passe mal, ou si l'on constate après coup une erreur de sens, la restauration ramène la base à l'état d'avant. Le coût en confiance est énorme : on ose lancer une synchro parce qu'on sait pouvoir revenir en arrière.

Deuxième garde fou, la comparaison avant application. Plutôt que d'appliquer aveuglément, l'outil montre ce qui va changer et signale les pertes potentielles : des articles présents en production mais absents en local, des réglages qui vont être remplacés. L'utilisateur valide en connaissance de cause, au lieu de découvrir les dégâts après.

Deux transports, REST et SFTP

Relier deux WordPress suppose un canal. J'ai retenu deux options, parce qu'aucune ne convient à tous les hébergements.

L'API REST est la voie la plus simple : le plugin dialogue de site à site en HTTP, avec authentification, sans rien installer d'autre. Elle suffit dans la majorité des cas et reste lisible à déboguer.

Le SFTP répond aux environnements plus verrouillés, où les échanges directs entre sites sont bloqués, ou lorsqu'on préfère faire transiter un fichier plutôt qu'un flux HTTP. C'est le mode de repli robuste quand le réseau ne coopère pas.

Laisser le choix évite le piège de l'outil qui marche sur la démo et échoue sur l'hébergement réel du client.

Fusionner le contenu, pas seulement l'écraser

La fonction dont je suis le plus content, parce qu'elle sort du schéma habituel des outils de migration. Au lieu de tout remplacer, on peut fusionner le contenu éditorial par type de publication. Rapatrier uniquement les articles, ou uniquement les produits, sans toucher au reste de la base.

C'est le scénario réel du quotidien : le contenu vit en production, où les rédacteurs travaillent, mais le développement se fait en local. On veut récupérer les nouveaux articles pour tester une mise en page, sans écraser les réglages du thème en cours de refonte. La fusion ciblée rend ce va et vient possible sans tout casser.

Depuis la 1.0, protéger les données vivantes de la production

La version 0.5 faisait son travail sur un site vitrine. Sur une boutique, il restait un angle mort que je connaissais trop bien : pousser ma base locale vers la production écrasait les commandes tombées pendant que je développais. C'est le défaut structurel de tous les outils de migration, ils remplacent la base entière ou rien.

La version 1.0 ajoute donc une case dans l'assistant de push, cochée par défaut dès que WooCommerce est présent : protéger les données vivantes de la cible. Les tables concernées sont exclues de l'export local, elles ne sont donc ni transférées, ni importées, ni réécrites. La production conserve son état.

// Motifs de tables préservées lors d'un push protégé
$prefix . 'wc_order'               // commandes HPOS, adresses, statistiques
$prefix . 'woocommerce_order_item' // lignes de commande
$prefix . 'woocommerce_sessions'   // paniers en cours
$prefix . 'wc_customer_lookup'     // clients
$prefix . 'wc_reserved_stock'      // stocks réservés par une commande
$prefix . 'users'                  // comptes créés depuis la mise en ligne
$prefix . 'usermeta'
$prefix . 'comment'                // notes de commande et avis produits

Une limite qu'il faut annoncer clairement : si la boutique stocke encore ses commandes dans wp_posts, c'est à dire en mode historique avec HPOS désactivé, ces lignes précises ne sont pas isolables au niveau de la table. L'assistant l'affiche explicitement plutôt que de laisser croire à une protection totale.

Sur ma propre boutique, plus de mille produits et près de quatre mille commandes, cette case change la nature de l'outil. On passe d'un plugin de clonage à un plugin de déploiement, utilisable sur un site qui encaisse pendant qu'on travaille.

Automatisation et robustesse, ce qu'apporte la 1.1

Trois chantiers ont suivi, tous nés d'un agacement concret.

Les synchronisations planifiées. Un pull nocturne qui rafraîchit la copie locale depuis la production, sans y penser. Fréquence au choix, verrou anti chevauchement pour que deux exécutions ne se marchent pas dessus, notification par courriel ou par webhook. Les raccourcis de déploiement en un clic peuvent eux aussi tourner tout seuls.

Les commandes WP-CLI. wp miroir status, compare, push et pull, avec les mêmes options que l'interface. De quoi brancher une synchronisation dans une chaîne d'intégration continue, ou dans un vrai cron système, nettement plus fiable que WP-Cron sur les gros volumes.

# Rafraîchir la copie locale chaque nuit depuis la production
0 3 * * * cd /chemin/du/site && wp miroir pull --profile=prod --db --yes

L'exécution en arrière plan. Une longue synchronisation tenait jusqu'ici dans une seule requête HTTP, ce que les hébergements mutualisés coupent volontiers au bout de soixante secondes. L'opération part désormais dans une requête serveur détachée, sur le modèle de WP-Cron, et le navigateur se contente d'interroger l'avancement. On peut fermer l'onglet, le travail continue. Si l'hébergeur bloque les requêtes en boucle locale, le plugin revient tout seul au mode synchrone d'avant.

S'y ajoutent des détails qui comptent à l'usage : la comparaison met en cache les empreintes de fichiers et devient quasi instantanée à la seconde analyse, un bouton libère le verrou de l'agent distant après une opération interrompue, et le journal d'activité s'exporte en CSV.

Dix-huit versions plus tard, ce que l'usage a corrigé

Depuis la 1.1, le plugin est passé par dix-huit versions correctives. Presque toutes viennent de problèmes rencontrés sur des sites réels, souvent les miens, plutôt que de fonctionnalités prévues à l'avance. Voici celles qui méritent d'être connues avant de l'installer.

Une fuite de données que je n'avais pas vue venir. C'est la correction la plus importante de la série, en 1.1.10. Le dossier de travail du plugin, celui qui reçoit les instantanés et les exports de base, se trouvait dans l'arborescence des médias. Un push de médias l'embarquait donc avec le reste, ce qui pouvait déposer sur la cible un dump complet de la base locale, empreintes de mots de passe et adresses courriel comprises. Le dossier est désormais exclu de ces transferts. Si vous avez poussé des médias avec une version antérieure, allez regarder ce que contient ce dossier sur la cible.

La même version élargit le chercher remplacer à toutes les écritures d'une URL : http et https, les liens sans protocole du type //exemple.fr, et les variantes avec ou sans www. Il suffit d'en oublier une pour se retrouver avec des images cassées après une migration.

La protection des données vivantes est allée plus loin que prévu. La case décrite plus haut est cochée par défaut depuis la 1.1.8, y compris pour les synchronisations planifiées et les commandes WP-CLI, qui suivaient encore leur propre logique. La 1.1.9 traite le cas que je n'avais pas anticipé : quand un push échoue et déclenche un retour arrière, restaurer l'instantané écrasait les commandes arrivées pendant l'opération. Le retour arrière est maintenant sélectif et laisse de côté les tables protégées. La 1.1.11 étend la liste de ces tables aux permissions de téléchargement, aux jetons de paiement et à la file d'attente d'Action Scheduler, et fait fonctionner l'ensemble même quand les deux sites n'utilisent pas le même préfixe de tables.

Des imports qui échouent bruyamment. Plusieurs versions ont porté sur ce qui se passe quand quelque chose tourne mal au milieu d'une importation. La 1.1.4 réconcilie les préfixes de tables entre les deux sites, utilise le bon jeu de caractères pour le dump comme pour l'import, et fait échouer l'export de repli en PHP au lieu de le laisser produire un fichier tronqué. Une erreur SQL survenue en cours de dump n'est plus rejouée. La 1.1.6 corrige une corruption de données provoquée par les signes pourcent dans ce même export de repli, avec une procédure de réparation pour les valeurs déjà abîmées.

La synchronisation de contenu est devenue différentielle. Jusqu'à la 1.1.14, fusionner un type de contenu transférait tous ses éléments, ce qui devient long dès qu'on a quelques milliers d'articles. Seuls les éléments manquants ou plus anciens que la source circulent désormais. La 1.1.17 permet de cocher plusieurs types à la fois dans l'écran de comparaison, traités les uns après les autres avec un avancement par type, et la 1.1.16 s'assure qu'on obtienne toujours une réponse explicite à la fin, y compris quand il n'y avait rien à transférer.

Voir ce qui se passe pendant une opération longue. Les versions 1.1.2, 1.1.3 et 1.1.18 ont surtout servi à rendre l'attente lisible : un écran d'avancement avec le temps écoulé et des libellés compréhensibles, un compteur d'octets transférés pendant les phases de compression et d'envoi, et la fin des boutons figés pendant la mise à jour de l'agent distant. Ce sont des détails d'interface, qui comptent surtout sur une opération de plusieurs minutes.

Le reste concerne le quotidien du travail sur plusieurs sites. Un bouton de reconnexion permet de réappairer un site sans supprimer son profil, donc sans perdre les raccourcis de déploiement associés, après l'expiration d'un mot de passe d'application. L'écran de comparaison indique l'activité éditoriale des sept derniers jours sur la cible, ce qui évite d'écraser un article publié la veille. Les thèmes et extensions présents sur la cible mais absents en local peuvent être supprimés depuis la comparaison, les éléments actifs étant refusés en garde-fou. Et l'agent conserve l'identité qui l'a autorisé lors d'un import, en vidant le cache objet derrière lui, ce qui évite de se retrouver enfermé dehors.

Un projet jeune, ouvert aux retours

Sur la maturité du projet, Miroir Local Sync en est à la version 1.1.18, testée jusqu'à WordPress 7.1 et publiée sur le dépôt officiel après validation par l'équipe de wordpress.org. Il demande WordPress 6.0 et PHP 8.1 au minimum, et le nombre d'installations reste modeste. Je le fais grandir au fil de mes propres besoins et des retours qui remontent. Le moteur de remplacement, la partie la plus risquée puisqu'elle réécrit une base entière, est couvert par une série de tests que n'importe qui peut lancer depuis le dépôt. Je le partage parce qu'il résout déjà un vrai problème pour moi, et parce que le publier reste le meilleur moyen de le rendre plus solide.

Si vous jonglez vous aussi entre local et production sur WordPress, le plugin est disponible et documenté sur le dépôt officiel : Miroir Local Sync sur wordpress.org. Les retours, les remontées de bug et les idées sont les bienvenus.

Ce travail prolonge une réflexion que j'aborde côté ERP dans Synchronisation bidirectionnelle ERP et site, les pièges des connecteurs temps réel, où les mêmes questions d'idempotence et de sens de flux reviennent, à une autre échelle.