The Manifest updater
Dashboard → Updates, served from your own forge. One JSON file, one release zip, a short class you own. No WordPress.org listing, no middleman, no license server.
Most plugin authors assume Dashboard → Updates only works for plugins on WordPress.org. It does not. WordPress asks a few filters "is there a newer version, and where is the package?" Answer those from a manifest.json in your repository and a zip attached to your release, and sites get the same Updates screen, the same one-click install, and the same "View details" modal they would get from the directory.
This is a convention, not a library. Copy the pattern, rename the class, change what you need. Disembark, Minn Admin, CaptainCore Manager, WP Freighter, WP Shipyard and a handful of other listed plugins ship it today. The 2023 walkthrough is the origin story; this page is the current guideline.
The three things that matter
- Publish a
manifest.jsonon the default branch (mainormaster) and point the updater at its raw URL. Sites read that file, not the release API. - Attach an installable zip to every release, named like the plugin folder (
your-plugin.zip). The forge's auto-generated "Source code (zip)" has the wrong root folder, breaks installs, and does not count as a download here. - Cache the check. Store the remote manifest in a transient for an hour (a day is fine), flush it after a successful update, and never hit the forge on every admin page load.
Everything below is detail, hardening, or optional extras.
What goes in manifest.json
The minimum the updater needs:
{
"name": "Your Plugin",
"slug": "your-plugin",
"author": "<a href=\"https://example.com\">You</a>",
"version": "1.2.0",
"download_url": "https://github.com/you/your-plugin/releases/download/v1.2.0/your-plugin.zip",
"requires": "6.0",
"tested": "6.9",
"requires_php": "7.4",
"homepage": "https://example.com",
"sections": {
"description": "One or two sentences for the plugin details modal."
}
}
Worth adding:
sha256— hex digest of the release zip, so the updater can refuse a tampered package.author_profile,donate_link,banners, and richersections(installation,changelog) — they fill the "View details" modal the way WordPress.org would.added/last_updated— timestamps that sites and directories can display.translations— language packs, covered below.
Keep version and download_url in lockstep with the release you just cut. Ship a copy of manifest.json inside the plugin too, so the version display and details modal still work when the forge is unreachable. That bundled copy is a fallback only: it cannot honestly contain its own zip's hash, so sha256 verification always reads the remote file on the default branch.
Where the updater looks
Pin one HTTPS URL to the raw file on the default branch:
https://raw.githubusercontent.com/you/your-plugin/main/manifest.json
Serving the same JSON from your own site also works (WP Freighter started that way), but the default-branch file is simpler, and it is what the WP Manifest crawler recognises as a manifest updater.
The release zip
- Build a zip whose root folder is the plugin slug (
your-plugin/your-plugin.php). - Attach it as a release asset, not only as the forge's auto-generated source archive.
- Point
download_urlat that asset (…/releases/download/v1.2.0/your-plugin.zip). - Keep the package on a host and path you control, and allowlist that prefix in the updater before handing the URL to WordPress.
WP Manifest counts only .zip release assets toward downloads and the active-site estimate. Source archives inflate the numbers without being installs.
What the PHP side does
Three hooks are the whole "library": a short class you can paste and rename.
plugins_api— when WordPress asks forplugin_informationabout your slug, return name, version, sections anddownload_linkfrom the manifest.site_transient_update_plugins— if the remoteversionis newer than the installed constant, put a response object in$transient->responsewithpackageset todownload_url.upgrader_process_complete— delete your transient so the next check sees the new local version.
Reference implementations: Disembark (app/Updater.php), Minn Admin (includes/class-minn-admin-updater.php), and CaptainCore Manager.
Caching
WordPress reads site_transient_update_plugins constantly: admin screens, cron, the admin-bar badge. Without a cache every read becomes a live request to your forge.
- Store the decoded remote manifest in a dedicated transient (
yourplugin_updater). - TTL: one hour if you want releases to surface promptly, one day if traffic is the worry.
- Set
cache_allowed = trueand actually honour it. A flag that always refetches defeats the point. - On a failed fetch, keep the previous transient or the bundled fallback rather than thrashing.
Hardening worth stealing
From the newer implementations (Minn Admin, CaptainCore Manager):
- Allowlist package URLs before download: HTTPS only, known hosts, path anchored under your
owner/repo/releases/download/. - Require
sha256in the remote manifest and verify it withhash_equalsonupgrader_pre_download. A missing hash means refuse, not skip. - Scope any SSL relaxation to the manifest URL, and only under a truthy
*_DEV_MODEon a non-production site. Never relax verification for the package download. - Keep the plugin header
Version:and the updater's version constant in sync, or read one from the other.
Translations (optional, but native)
You do not need translate.wordpress.org to offer language packs. Put them on the same release and list them in the manifest:
"translations": [
{
"language": "de_DE",
"version": "1.2.0",
"updated": "2026-09-11 12:00:00",
"package": "https://github.com/you/your-plugin/releases/download/v1.2.0/your-plugin-de_DE.zip",
"sha256": "…"
}
]
Then, in the same site_transient_update_plugins filter, append matching entries to $transient->translations. WordPress installs them into wp-content/languages/plugins/ and refreshes them after a plugin update on its own.
Lessons from Minn Admin:
- Ship
.po+.mo+.l10n.php(and a JS JSON file if you have a script handle). Without the.po, core thinks nothing is installed and re-offers the pack forever. - Prefer language packs over bundling every locale in the plugin zip: smaller downloads, packs survive reinstalls, and Updates → Translations just works.
- Version per language: bump a pack's
versiononly when that catalog changed, so a Spanish-only fix does not force every locale to re-download. - Offer packs only for locales the site actually uses (site locale plus user locales), not every language installed on the server.
- Verify language-pack zips with the same host allowlist and
sha256gate as the plugin zip.
Cutting a release
- Bump
Version:in the main plugin file (and any mirrored constant). - Build
your-plugin.zipwith the correct root folder. - Create the forge release (
v1.2.0) and attach the zip, plus any language-pack zips. - Compute
sha256of each zip. - Update
manifest.jsonon the default branch:version,download_url,last_updated,sha256, andtranslationsif you ship packs. - Commit and push. Sites pick it up on the next cached check.
Automate steps 4 and 5 once the project grows. Minn Admin's bin/release-manifest.js stamps hashes and translation entries without hand-editing JSON.
Let an agent wire it up
If you work with Cursor, Claude, Copilot or similar, you do not have to write this by hand. Open your plugin repo, paste one of these prompts, and let the agent adapt a real reference implementation. Swap the reference URLs for CaptainCore Manager or WP Shipyard if you prefer those as the template. The agent should read those files and rename the class, slug and cache key for your plugin, not invent a parallel update system.
Baseline — same shape as Disembark
Wire up plugin updates for this plugin the same way Austin Ginder's Disembark plugin does.
Reference: https://github.com/DisembarkHost/disembark
- app/Updater.php
- manifest.json on the default branch
- release zip attached to GitHub Releases (not the auto-generated source archive)
Requirements:
1. Add a root manifest.json with name, slug, version, download_url, requires, tested, requires_php, homepage, and a short sections.description.
2. Add a small Updater class that filters plugins_api and site_transient_update_plugins, caches the remote manifest in a transient, falls back to the local manifest.json if GitHub is unreachable, and purges the cache after a successful update.
3. Point the remote check at the raw manifest.json on this repo's default branch.
4. Document the release steps: bump Version, build an installable {slug}.zip, attach it to the GitHub release, update manifest.json version + download_url, push the default branch.
5. Do not add Composer packages or Plugin Update Checker unless I ask. Keep it a short class I own.
With translations — same shape as Minn Admin
Set up this plugin to update from GitHub, including translations, like Minn Admin. Reference: https://github.com/austinginder/minn-admin - includes/class-minn-admin-updater.php - manifest.json (including translations[] and sha256) - language packs as separate release assets - docs/i18n-roadmap.md for the delivery model Requirements: 1. Manifest-based plugin updates from the raw manifest.json on the default branch, with a cached transient and local fallback. 2. Allowlist package URLs (https, this repo's /releases/download/ path) and require sha256 verification on download. 3. Offer language packs through $transient->translations from manifest.translations[], only for locales the site actually uses. 4. Each language-pack zip should include .po, .mo, and .l10n.php (plus JS JSON if we enqueue scripts), installed to wp-content/languages/plugins/. 5. Stamp sha256 for the plugin zip and each pack into manifest.json as part of the release process; do not trust the copy of the manifest bundled inside the plugin zip for verification. 6. Keep the implementation in-repo (no new SaaS). Match Minn's behaviour, adapted to this plugin's slug and namespace.
How this shows up on WP Manifest
- Plugins that follow this pattern are counted under self-updates on the Usage page, in the
manifest.jsonbucket. - A root
manifest.jsonwithdownload_urlorsectionsis one of the hunt signals that gets a new repository found. - Release zips feed the download counts and the active-site estimate described on About.
You do not register anything with WP Manifest for updates to work. The directory indexes what you already ship; the updater talks only to your forge.
Related options
- Plugin Update Checker — battle-tested library; many listed plugins use it. Heavier than a short class, but it handles more hosts and edge cases.
- Git Updater — a site-side plugin that reads Git hosts. Headers alone do nothing unless the site has Git Updater installed.
Update URI:header — tells WordPress not to look on WordPress.org for a same-named slug. Set it even when you self-host; it prevents a directory plugin with the same slug from overwriting yours.
The Manifest updater is for authors who want Dashboard → Updates without a middleman and without a large dependency.
Examples
- Disembark — the classic pattern with local fallback ·
manifest.json - Minn Admin —
sha256, allowlists, language packs ·manifest.json - CaptainCore Manager — same hardening as Minn ·
manifest.json - WP Freighter — the original GitHub-based flow ·
manifest.json - WP Shipyard — a fresh minimal example ·
manifest.json
Questions, or a listing that should credit a Manifest updater and does not: feedback@wpmanifest.xyz.