WP Manifestindependent plugin directory
manifest / updates / resumable-wordpress-content-migration

Resumable Content Migration

A resumable, idempotent WordPress content migration command in PHP 8.2.

★ 0stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/andrewshon-e/resumable-wordpress-content-migration/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

A small WordPress plugin that imports newline-delimited JSON through WP-CLI. It is designed for migrations that need to be safe to preview, stop, and resume without creating duplicate posts.

The interesting part is not the call to wp_insert_post(). It is the boundary around it: streaming input, strict validation, stable change detection, failure-aware checkpoints, and a testable core that does not need WordPress to run.

What it demonstrates

  • PHP 8.2 features, including enums, readonly classes, named arguments, and strict types
  • dependency inversion between migration policy and WordPress infrastructure
  • bounded-memory JSONL processing for large exports
  • idempotent create/update/skip decisions using a SHA-256 content fingerprint
  • indexed source-ID lookups by hashing the ID into a private meta key while retaining the original value
  • dry runs that never write posts or checkpoints
  • resumability without silently skipping a failed database write
  • output sanitization at the WordPress boundary
  • PHPUnit coverage and PHPStan at its strictest rule level

Processing model

For each non-empty line, the runner:

  1. decodes and validates the source record;
  2. looks up a post by its stable legacy ID;
  3. compares a canonical fingerprint when that post already exists;
  4. creates, updates, or skips the post;
  5. advances the checkpoint only after a successful write.

Malformed source data is reported and skipped because retrying the same invalid input cannot fix it. A repository or database failure stops the run and leaves the checkpoint before the failed line, so a later invocation retries that record. Dry runs deliberately do not advance the checkpoint.

Source format

The importer expects one JSON object per line:

{"source_id":"legacy-1001","title":"A practical guide to civic data","body_html":"<p>Imported body.</p>","status":"publish","published_at":"2025-11-07T14:30:00+00:00","author_email":"editor@example.com","slug":"practical-guide-civic-data"}

status must be draft, private, or publish. Timestamps must use RFC 3339. The author email must already belong to a WordPress user; treating a missing author as an error avoids silently assigning editorial content to the wrong person.

See fixtures/articles.jsonl for a two-record example.

Install and run

The project requires PHP 8.2 or newer and Composer.

composer install
composer check

Install the directory as a WordPress plugin, activate it, and preview an import:

wp plugin activate resumable-content-migration
wp content migrate /srv/import/articles.jsonl \
  --id=articles-2026-08 \
  --dry-run

Run the write pass after reviewing the dry-run output:

wp content migrate /srv/import/articles.jsonl --id=articles-2026-08

By default, the checkpoint is saved to wp-content/migration-checkpoints/<id>.json. --checkpoint=<path> can move it to durable shared storage. Reusing the same migration ID and unchanged source file resumes after the last completed line.

The command also accepts --post-type=<post-type>. The destination type must already be registered.

Code map

Deliberate trade-offs

This sample assumes one writer for a given migration ID. Source IDs are hashed into private meta keys because WordPress indexes meta_key but not the complete meta_value; the original ID remains in the value to detect a theoretical hash collision. Post meta still does not enforce uniqueness, so parallel workers could race between lookup and insert. For a parallel production migration, I would add a dedicated mapping table with a unique source-ID index and claim work in transactions.

The importer uses wp_kses_post() because this example treats the legacy source as untrusted. A real discovery phase would inventory the source markup and define an explicit allowlist before migration; otherwise valid embeds or custom elements could be lost.

Checkpoints identify a line number rather than a hash of the source file. Once a write run starts, the source should be treated as immutable. A production extension could store the file hash and reject a resume when the source changes.

License

MIT