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
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.zipReadme
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:
- decodes and validates the source record;
- looks up a post by its stable legacy ID;
- compares a canonical fingerprint when that post already exists;
- creates, updates, or skips the post;
- 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
src/MigrationRunner.phpowns orchestration and failure semantics.src/Domain/LegacyArticle.phpvalidates records and creates stable fingerprints.src/Infrastructure/JsonLinesArticleSource.phpstreams input and isolates malformed JSON.src/Infrastructure/FileCheckpointStore.phpreplaces checkpoints atomically.src/WordPress/WordPressArticleRepository.phpcontains the WordPress persistence and sanitization boundary.tests/MigrationRunnerTest.phpcovers idempotency, dry runs, invalid records, failures, and resume behavior.
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