DAPD Post Porter
Standalone WordPress plugin for secure, checksummed single-post export and import.
by DAPD · github.com/dexter-adams/dapd-post-porter · website
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/dexter-adams/dapd-post-porter/archive/refs/heads/main.zipReadme
DAPD Post Porter
Move one WordPress post between sites without a full database or WXR export.
Post Porter packages the selected post, its raw metadata, terms, featured image, content images, author mapping data, and referenced synced patterns into a checksummed JSON document. The destination site validates that document, safely downloads its images, rewrites successful URL mappings, and recreates the post.
About this code sample
What I wrote. All of it — the export format, the two import paths, the security model, the WP-CLI commands, and the tests. It is a standalone plugin with no dependency beyond WordPress core.
Why I wrote it. I kept hitting the same problem on client work: I needed to move one post — a long, block-built page with ACF and Yoast metadata, synced patterns, a dozen images, and its taxonomy terms — from staging to production. A database dump moves everything, including things that must not move. A WXR export gives up on synced patterns and serialized metadata, and reattaches media unpredictably. Both are the wrong size for the job. Post Porter is the right size: one post, everything attached to it, and nothing else.
Why I am proud of it. The interesting part of this problem is not the
export — it is deciding what to believe on the way back in. An import file is
untrusted input that arrives asking to create content, assign an author, and
publish. So the plugin treats it that way: the payload is size-capped and
structurally validated before any state exists, its integrity checksum is
required rather than optional, remote images must pass WordPress's own URL
validation, a requested publish is downgraded when the user cannot publish
that post type, and an author can only be mapped onto an account the caller
could already edit. Failure behavior got the same attention as success:
overwrite updates in place instead of deleting first, so a failed import can
never leave a site missing a post it had before.
What it demonstrates. WordPress internals used deliberately — block parsing, synced-pattern remapping, raw serialized metadata, the sideload APIs, capabilities, transients, WP-CLI. It also shows how I handle the case where the same rules have to run in two very different shapes: a synchronous CLI import and a resumable AJAX import driven by a progress bar. Rather than let those two paths keep private copies of the rules, they share one validator, one policy object, and one post writer, so the browser and the terminal cannot quietly disagree about what a file is allowed to do.
Why this repository is useful
This is a standalone WordPress plugin and a focused example of:
- namespaced, autoloaded PHP organized by responsibility;
- capability and nonce enforcement on admin and AJAX operations;
- resumable, user-scoped AJAX imports backed by expiring transients;
- one shared rule set behind two different import orchestrations;
- safe remote media handling using WordPress URL validation and sideload APIs;
- raw serialized metadata migration without double serialization;
- duplicate handling that updates in place instead of deleting first;
- WordPress block parsing and synced-pattern ID remapping;
- a WP-CLI interface for repeatable migration work; and
- dependency-free unit tests for the decisions made before anything is written.
It depends only on WordPress core. No theme, site-specific plugin, ACF, or third-party service is required.
Requirements
- WordPress 6.0 or newer
- PHP 7.4 or newer
Install
- Download or clone this repository into
wp-content/plugins/dapd-post-porter. - Activate DAPD Post Porter in WordPress.
- Export from a post row action or the block-editor status panel.
- Import from Tools → Post Porter.
The editor integration uses WordPress's registered browser packages directly, so the plugin has no JavaScript package or build-time dependency tree.
WP-CLI
wp dapd-porter export 123 --output=./example.json
wp dapd-porter import ./example.json --duplicate=skip
wp dapd-porter import ./example.json --duplicate=overwrite --author=7
wp dapd-porter import ./example.json --dry-run
overwrite updates an existing matching post in place. It does not delete the
original before the replacement data has been validated.
Security model
- Browser exports require a valid nonce and
edit_postfor the exact post. - Browser imports require WordPress's
importcapability and a valid nonce. - Uploads must be
.json, are capped at 10 MB by default, and are parsed and structurally validated before import state is created. - Every import must carry a valid
sha256:integrity checksum. A checksum that is missing, malformed, or does not match the payload stops the import before any write. Absent integrity data is treated as a failure, not as permission to skip the check — see Importing legacy files. - Remote media URLs must pass
wp_http_validate_url(), which rejects unsafe destinations. Sites may add a domain allowlist with thedapd_post_porter_allow_image_urlfilter. - Chunked import sessions are bound to the WordPress user who created them.
- Imported publish states are downgraded to draft when the current browser user cannot publish that post type.
Export files can contain private post metadata and an author's email address. Treat them as sensitive migration artifacts.
Importing legacy files
Every export this plugin has ever produced is checksummed, so the requirement
is invisible in normal use. If you need to import a hand-written or historical
document that has no checksum field at all, opt out deliberately and
temporarily:
add_filter( 'dapd_post_porter_require_checksum', '__return_false' );
The filter receives the payload's declared schema version as its second argument, so the exception can be narrowed to the specific format being migrated. It only permits a missing checksum. A checksum that is present is always verified, so this can never be used to import a tampered file.
How the two import paths stay in sync
Post Porter imports the same file in two shapes. WP-CLI runs the whole import in one synchronous pass. The browser runs it as resumable steps so an import with thirty images does not die to a PHP timeout, with progress reported to a JavaScript progress bar between steps.
Those are genuinely different orchestrations, and they stay separate. What they do not do is each carry their own copy of the rules:
| Concern | Owner |
|---|---|
| Schema, integrity, post type, create capability | Import\ImportValidator |
| Duplicate strategy, author resolution, publish status | Import\ImportPolicy |
| Canonical post array, insert vs. update, media attachment | Import\PostWriter |
Importer and ChunkedImporter decide when to ask; these decide what the
answer is. That boundary is the point: a browser import and a CLI import of
the same file must produce the same post, and duplicated policy is how that
guarantee quietly rots. The pure decisions are unit-tested directly.
Filters
add_filter(
'dapd_post_porter_max_import_bytes',
static fn () => 5 * MB_IN_BYTES
);
add_filter(
'dapd_post_porter_allow_image_url',
static function ( bool $allowed, string $url ): bool {
return $allowed && 'media.example.com' === wp_parse_url( $url, PHP_URL_HOST );
},
10,
2
);
add_filter(
'dapd_post_porter_skipped_meta_keys',
static function ( array $keys ): array {
$keys[] = '_private_integration_token';
return $keys;
}
);
Development
php tests/run.php
node --check js/editor-sidebar.js
node --check js/import-progress.js
CI syntax-checks every PHP and JavaScript file and runs the test suite.
License
GPL-2.0-or-later. See LICENSE.txt.