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

Resumable Content Migration

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

by Andrew Shon · github.com/andrewshon-e/resumable-wordpress-content-migration

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/andrewshon-e/resumable-wordpress-content-migration/archive/refs/heads/main.zip

Readme

Resumable WordPress Content Migration

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

Read the full README on GitHub →