Skip to content
Numan Zahid
Get in touch
All work

A plugin that pulls Substack posts into WordPress

Publishing integration · Plugin architecture, safety design and tests

WordPressPHP 8WP-CronPHPUnitComposer
5Conditions that abort a removal pass before it can trash anything
12Unit test suites across sync, safety and parsing logic
50Posts per batch, resumable across scheduled runs
Starting point
  • A publication was writing in Substack but needed those articles on its own site as real WordPress posts, indexed and templated, rather than embedded in someone else's iframe.
  • They also wanted to keep publishing directly in WordPress. So the posts list had to hold two kinds of post at once, mirrored and locally written, and behave sensibly for both without splitting the team's work across two places.
  • That is where these tools usually fail: a sync running on a schedule and a person working in the admin eventually touch the same post, and whichever writes last destroys the other's work silently. Nobody notices until the writing is gone.
  • Mirrored articles still needed local enrichment. Categories, tags and the rest of the site's taxonomy are the site's own concern, and the upstream publication knows nothing about them.
What I did
  • Let both kinds of post share one list. Locally written posts are created and edited exactly as normal, and mirrored ones sit beside them under the same templates and the same taxonomy.
  • Made ownership configurable rather than hardcoded. The site chooses which fields the sync is responsible for, and everything outside that selection belongs to the editor permanently and survives every subsequent run.
  • Enforced that boundary where the data is written instead of in the editor UI. Fields the sync owns reject manual writes at the metadata layer, so the rule holds whether the write arrives from the admin, the REST API or WP-CLI.
  • Tracked sync depth so the plugin can tell its own writes from a person's, and lifts its own guard only while it is the one writing.
  • Classified every article body before importing it, so a teaser can populate an excerpt but can never be published as the post content.
  • Treated deletion as the dangerous operation it is. Posts are trashed only after a complete catalogue scan, and any disagreement between the catalogue and what is stored aborts the entire pass rather than acting on a partial view.
Measured outcome
  • The team works in one posts list. Articles written upstream appear as native posts, articles written in WordPress are published as normal, and the site templates both the same way.
  • Mirrored posts can be categorised, tagged and enriched locally, and that work is never undone by the next sync, because those fields were never the sync's to write.
  • An editor cannot accidentally fight the sync, and the sync cannot quietly undo an editor, since the boundary between them is enforced at the point of writing rather than agreed by convention.
  • A failed or partial fetch stops the pass instead of deleting anything, so the worst outcome of a bad run is that nothing happens.
  • Uninstalling leaves every imported post and image in place. Removing the tool never removes the content it brought in.
Technical detail
  • Field protection runs on the metadata and post-insert filters rather than in JavaScript. A readonly input is a suggestion; a filter that rejects the write is the actual guarantee, and it applies to every write path into the post, not just the editor screen.
  • Because the owned-field set is configuration rather than a constant, the same plugin serves a publication that wants titles and bodies mirrored but everything else local, and one that wants as much as possible carried across. Neither case is a fork.
  • Identity is a publication ID plus an upstream post ID in a dedicated mapping table, with post meta as a fallback. Matching on slug or title would re-import everything the first time someone renames an article.
  • Nullable API fields go through a three-state model that separates 'empty' from 'not returned'. Without that distinction, a field missing from one response silently erases the value already stored.
  • The removal pass aborts if the catalogue is incomplete, empty, or disagrees with what is stored. Trashing on a partial scan would delete a publication's archive because one paginated request timed out.
  • Posts trashed by a person are never restored, but posts the plugin trashed are restored if the article reappears upstream, so the two kinds of deletion never get confused with each other.
  • Inline images are only rewritten when the source host matches the publication's media domains, and attachments are deduplicated on the original URL, so re-running a sync does not fill the media library with copies.
  • A lock with a timed expiry prevents overlapping runs, since a scheduled run that outlives its own interval would otherwise race the next one through the same batch.

Got a project?

Email me with where things are now and what you'd like to change. If I'm not the right person for it, I'll tell you, and I'll usually know someone who is.

hello@zahid.dev