Updates for deactivated plugins: how my WordPress plugins update each other

WordPress doesn't run deactivated plugins, so it doesn't run their updater either. How updater 1.5.0 solves this for my plugin collection: registry, leader election, checksum gate — using JPKCom Harden & Trim as the example.

by ·

In July I described in a making-of how my WordPress plugins update themselves outside wordpress.org: GitHub Actions builds the ZIP, the checksum and the manifest, and a small updater class in the plugin hooks into the WordPress update system. It has worked reliably since — with one gap that is built into the design itself. A deactivated plugin never gets an update.

The reason is simple: WordPress doesn't load the code of deactivated plugins. But the updater class lives inside the plugin. When the plugin is off, its updater doesn't run, and WordPress never learns that a new version exists. The old plugin stays on disk, and whoever switches it back on a year later switches on the version from a year ago.

With the releases of 21 October 2026, this is fixed for all my plugins. Each one now ships updater 1.5.0, and a single loaded copy updates the whole collection — active and deactivated plugins alike. In this article I show how that works, where the security traps are and what you need to do as a site owner. The running example is JPKCom Harden & Trim, my newest plugin; apart from namespace and slug, the updater is identical in all plugins.

If you only want to know what changes for your site, jump to the short version or the checklist.

The short version for site owners

What Up to updater 1.4 From updater 1.5.0
Deactivated JPKCom plugin never gets an update gets updates as soon as any JPKCom plugin with updater 1.5.0 or later is active (standard folder, no Git checkout)
"View details" only for active plugins for all installed JPKCom plugins
Checksum (SHA-256) for active plugins for every plugin, including a package another plugin supplies
Download URLs in the database stored no longer stored, only present while an updater is running
Git checkout, renamed folder was overwritten with the ZIP notice of the new version, but no automatic update
What triggers the update fetch any request that reads the update data only accounts allowed to update, WP-Cron and WP-CLI

For this to work, at least one active JPKCom plugin must be on its version with updater 1.5.0 — for Harden & Trim that is 1.1.3, for Post Filter 1.4.7, and for the other plugins the version is in their changelog. The new protections only apply to an active plugin once it is on that version itself, though, so bring every active plugin there once.

The problem in detail: why wordpress.org plugins can do this

With plugins from the official directory, you never run into this problem. Even a deactivated plugin shows "There is a new version of … available." there. That is not down to the plugin but to WordPress itself: the function wp_update_plugins() reads all installed plugins via get_plugins() — active and deactivated — and sends the list to api.wordpress.org. The answer ends up in the site transient update_plugins, from which the update screen builds its rows.

A self-hosted plugin doesn't have that route. It has to enter itself into the transient, and for that it needs running code: a filter on site_transient_update_plugins that adds its own row when the transient is read. The updater inside the plugin registers that filter. A deactivated plugin never registers it.

The obvious ways out all have a catch:

  • A separate update plugin that serves all the others. Every site owner then has to install it as well and keep it active, and it becomes a single point of failure.
  • A must-use plugin that always runs. It can't be installed and updated through the normal plugin route.
  • Load the deactivated plugin's code anyway. That contradicts what "deactivated" means to the site owner.

My solution uses what is already there: each of my plugins carries a copy of the updater. As long as even one of them is active, at least one copy runs. It only needs to know that the others exist.

The idea: a family instead of loners

Updater 1.5.0 turns the individual copies into a family. Every copy enters itself into a shared list when it starts, one of them is elected and does the work for all installed JPKCom plugins, and the others stay passive.

Updater family (updater 1.5.0)
 init, priority 5
 ┌─────────────────────────┐   ┌─────────────────────────┐
 │ ACF Jobs (active)       │   │ Allow Block Types       │
 │ Updater 1.5.0           │   │ (active), Updater 1.5.0 │
 └────────────┬────────────┘   └────────────┬────────────┘
              └──────────┬──────────────────┘
                         ▼
          $GLOBALS['jpkcom_git_updater_registry']
                         │  highest UPDATER_VERSION wins,
                         │  ties go to the first copy
                         ▼
      Leader: e.g. the copy from ACF Jobs
                         │
     ┌───────────────────┼───────────────────────┐
     ▼                   ▼                       ▼
 ACF Jobs           Allow Block Types     Harden & Trim (deactivated)
 update row,        update row,           found by folder scan:
 icon, details      icon, details         update row, icon, details

Three building blocks carry this: the registry, the leader election and the search for deactivated plugins.

The registry: a list in $GLOBALS

Every copy of the updater lives in its own namespace — JPKComHardenTrimGitUpdate for Harden & Trim, a different one for ACF Jobs. That is deliberate: the classes don't collide, even when two plugins ship different updater versions. The downside is that the copies share nothing, not even static properties. The only shared place in PHP is global state, so every copy enters itself there in its constructor:

register() — abridged
private const REGISTRY = 'jpkcom_git_updater_registry';

private function register(): void {
    if ( ! isset( $GLOBALS[ self::REGISTRY ] ) || ! is_array( $GLOBALS[ self::REGISTRY ] ) ) {
        $GLOBALS[ self::REGISTRY ] = [];
    }

    $GLOBALS[ self::REGISTRY ][] = [
        'v'        => self::UPDATER_VERSION,   // '1.5.0' — decides the election
        'id'       => spl_object_id( $this ),
        'file'     => plugin_basename( $this->plugin_file ),
        'slug'     => $this->plugin_slug,
        'manifest' => $this->manifest_url,
        'version'  => $this->current_version,
    ];
}

The entries are deliberately plain arrays of scalar values. No copy ever calls a method on another copy's entry, so a broken entry can't take another copy down with it. According to the code comment, future versions may add keys but must not rename or retype them.

The leader election: highest version wins

A simple comparison decides who does the work: the copy with the highest UPDATER_VERSION wins, and ties go to the one registered first. That has a nice side effect: if I release updater 1.6.0 later, the newest copy takes over automatically as soon as the first active plugin has been updated to it.

elect() — abridged
private function elect( array $entries ): ?int {
    $best = null;
    foreach ( $entries as $entry ) {
        // Looser than the registry check on purpose: a future copy that changes
        // its entry shape must still win, instead of leaving two leaders.
        if ( ! $this->looks_like_a_copy( $entry ) ) {   // abridged
            continue;
        }
        if ( null === $best || version_compare( $entry['v'], $best['v'], '>' ) ) {
            $best = $entry;   // strictly greater: ties stay with the first copy
        }
    }
    return null !== $best ? $best['id'] : null;
}

One subtlety is the timing. The plugins start their updaters one after another on init at priority 5. If WordPress reads the update transient before all copies have registered, a result cached from a single election would be wrong. So every copy re-elects on each hook call — but only if the registry has grown since the last election. All copies register all hooks; every callback that works for the family asks is_leader() and returns immediately in every copy except the leader.

Finding deactivated plugins

From the registry, the leader only knows the active plugins. It has to find the deactivated ones itself. For that it reads the plugins directory and checks every folder that looks like a JPKCom plugin:

discovered() — abridged
private const PLUGIN_URI_PATTERN = '~^https://github\.com/JPKCom/(jpkcom-[a-z0-9-]+)/?\z~';

foreach ( scandir( WP_PLUGIN_DIR ) as $slug ) {
    $path = WP_PLUGIN_DIR . '/' . $slug . '/' . $slug . '.php';   // canonical layout only
    if ( 1 !== preg_match( self::SLUG_PATTERN, $slug ) || ! is_file( $path ) ) {
        continue;
    }

    $data = get_plugin_data( $path, false, false );
    // Plugin URI must point to the JPKCom repository of exactly this slug.
    if ( 1 !== preg_match( self::PLUGIN_URI_PATTERN, $data['PluginURI'] ?? '', $match )
         || $match[1] !== $slug ) {
        continue;
    }

    $found[ "$slug/$slug.php" ] = [ /* slug, version, manifest URL, frozen … */ ];
}

Three decisions are built into this:

  • The identifying mark is the Plugin URI, not the newer Update URI header. Every version of every one of my plugins ever released carries the Plugin URI; the Update URI header only came with the releases of 19 October 2026. That way the leader also finds a deactivated plugin that has been sitting on an old version for months.
  • Only the standard layout <slug>/<slug>.php, and only folders starting with jpkcom-. The scan runs in every request that reads the update transient, so it shouldn't parse other plugins' headers.
  • scandir() instead of glob(): if the path to the plugins directory contains square brackets, a glob pattern would turn them into a character class and silently find nothing.

Finally, the leader checks whether the code of a found plugin was actually loaded in this request — via get_included_files(), not via the active_plugins option. The difference matters with wp --skip-plugins=…: a plugin is then listed as active in the database, but its code doesn't run. The leader treats it like a deactivated one.

What the leader then does

For every member of the family, the leader fetches the manifest from GitHub Pages and, when the transient is read, writes an update row with the version, icon and compatibility details. It also answers the plugins_api filter for all members, so "View details" works for a deactivated plugin too. That is not just convenience: without an answer, WordPress would fall back to wordpress.org for the details window — and a different plugin could be sitting there under the same slug.

The leader also copes with older copies. If an active plugin still runs an updater older than 1.5, that copy doesn't know the registry and keeps serving its own plugin. The leader recognises this (loaded, but not registered) and writes neither an update row nor details for that plugin, so that two updaters never disagree about manifest and package. Only if the old copy supplies no package at download time does the leader's gate check the checksum in its place.

The security side: who actually installs what?

An updater that offers packages for other plugins is a delicate component. The checksum from the first article remains the foundation, but the family opens new routes by which an unverified package could slip through. Updater 1.5.0 explicitly closes three of them.

No download URLs in the database

The update transient is stored in the database. If it contains a package's download URL, WordPress can install it later — even in a request in which no JPKCom updater is loaded at all, for example when all my plugins are deactivated or WP-CLI runs with --skip-plugins. Then nobody checks the checksum.

So the leader removes its family's package URLs before the transient is stored and puts them back on every read:

strip_packages() — abridged
// Hooked on pre_set_site_transient_update_plugins at PHP_INT_MAX.
public function strip_packages( mixed $value ): mixed {
    if ( ! $this->is_leader() || ! is_object( $value ) || empty( $value->response ) ) {
        return $value;
    }

    $members = $this->members();
    $value   = clone $value;
    foreach ( $value->response as $file => $entry ) {
        $package = is_string( $entry->package ?? null ) ? $entry->package : '';
        if ( isset( $members[ $file ] ) || 1 === preg_match( self::PACKAGE_PATTERN, $package ) ) {
            $entry          = clone $entry;
            $entry->package = '';          // re-injected on every read by check_update()
            $value->response[ $file ] = $entry;
        }
    }
    return $value;
}

If WordPress finds an empty package during an update, it refuses the update with its own message instead of installing an unverified ZIP. The code comment names the limit itself: a third-party plugin that writes the option straight into the database bypasses this filter. It is a best-effort safeguard, not a proof.

The checksum gate also checks other plugins' packages

The most important hook is still upgrader_pre_download. It fires before WordPress downloads a package and may abort the process or supply a file itself. With several updaters and possibly other plugins hooking in there, a new question arises: what if an earlier filter has already returned a file?

Up to 1.4, the gate sat at the default priority 10 and only checked its own plugin's package; a filter hooked in later could still replace its verified file. Since 1.5.0 it hooks in very late, at priority PHP_INT_MAX - 100, and treats every package belonging to the family the same way: it computes the SHA-256 of what would actually be installed, no matter who supplied the file.

verify_download_checksum() — abridged
// Fail closed: no manifest or no checksum is an error, not a legacy case.
$remote = $this->get_remote_manifest( $slug, $manifest );
if ( ! $remote ) {
    return new \WP_Error( 'manifest_unavailable', /* … */ );
}
if ( empty( $remote->checksum_sha256 ) ) {
    return new \WP_Error( 'checksum_missing', /* … */ );
}
$expected = strtolower( trim( $remote->checksum_sha256 ) );

if ( false !== $reply ) {
    // Someone already supplied the package: hash what will be installed.
    $hash = hash_file( 'sha256', $reply );
    return hash_equals( $expected, $hash ) ? $reply : $this->checksum_error( $hash, $expected );
}

$temp_file = download_url( $url );
$hash      = hash_file( 'sha256', $temp_file );
if ( ! hash_equals( $expected, $hash ) ) {
    wp_delete_file( $temp_file );
    return $this->checksum_error( $hash, $expected );
}

// Hand over the verified file, so WordPress installs exactly these bytes.
return $temp_file;

Two details from the time since the first article are in here too. Since updater 1.3.0 the check is mandatory: if the manifest has no checksum, the update is aborted. The making-of still showed the lenient variant; anyone able to tamper with the manifest would only have had to delete one field to switch the check off. And the gate hands the verified file straight to WordPress. If it only reported "fine", WordPress would download the package a second time and install bytes nobody had checked.

For an install by URL, such as wp plugin install <url>, the call carries no plugin name. The gate then matches the package by its file name <slug>.zip, not by one particular spelling of the address. GitHub serves the same release file under releases/latest/download/, in any mix of upper and lower case, with www., over http and with a query string. Every one of these variants has to pass the checksum. The code comment states the principle behind it: claiming too much is safe, because a ZIP that doesn't match the manifest is refused.

What triggers the fetch

A leader fetching manifests for up to 19 plugins must not slow any page down. The update transient is also read on the front end, for example for the admin bar. Since 1.5.0, when update data is merely displayed, the updater fetches an uncached manifest only under three conditions:

  • for logged-in accounts with the update_plugins capability,
  • in a WP-Cron run, such as WordPress's update cron,
  • in WP-CLI.

An anonymous visitor's page view never fetches a manifest itself; at most a cron run does that in the background. On top of that come a budget of at most five uncached fetches per interactive request, a circuit breaker that backs off for a quarter of an hour when GitHub Pages is down or slow, and staggered cache lifetimes: each manifest lives for one day plus a slug-dependent extra of up to six hours. That way not all caches expire in the same request.

Where the updater deliberately doesn't update

The updater marks two cases as frozen: the plugin learns about a new version but gets no package.

Git and other version-control checkouts. If the plugin folder contains .git, .svn, .hg or .bzr, an update would replace the folder with the contents of the ZIP and wipe the working tree. The updater takes the folder list and the automatic_updates_is_vcs_checkout filter from core's automatic updates. Unlike core, though, it checks the individual plugin folder: core only looks at the plugins directory and everything above it, and doesn't notice a checkout inside a single plugin at all. You update a checkout with git pull.

Renamed folders. If you download a repository's source as a ZIP from GitHub, the folder inside is called jpkcom-harden-trim-main. The release ZIP, however, unpacks to jpkcom-harden-trim/. An update would delete the renamed folder and put a new one next to it — and the active plugin would be gone. So here, too, there is only the notice.

In the plugin list, WordPress shows its own message in both cases ("Automatic update is unavailable for this plugin."). If someone tries an update anyway, for example via Dashboard → Updates or via a stale row someone else wrote, the checksum gate refuses it with the no_package error ("Update package not available."). One limitation matters: the updater doesn't find a deactivated plugin in a renamed folder at all, because the scan only knows the standard layout. It then gets no notice either.

Worked example: Harden & Trim deactivated

Here is what this looks like in practice. On a site, JPKCom ACF Jobs and JPKCom Allow Block Types are active, both with updater 1.5.0. Harden & Trim is installed but deactivated, and is on 1.1.3 — the first version with updater 1.5.0. On 21 October, 1.2.0 comes out with the new WP-CLI command wp harden-trim.

  1. WordPress's update cron reads the update_plugins transient. Both active plugins start their updater on init and register. With equal versions, the copy registered first wins.
  2. During the folder scan, the leader finds jpkcom-harden-trim/jpkcom-harden-trim.php, checks the Plugin URI and establishes that the code isn't loaded. Harden & Trim becomes a member of the family.
  3. It fetches https://jpkcom.github.io/jpkcom-harden-trim/plugin_jpkcom-harden-trim.json, compares 1.1.3 with 1.2.0 and writes an update row with the JPKCom logo as its icon. Before WordPress stores the transient, it removes the package URL again.
  4. Under Dashboard → Updates, Harden & Trim appears with version 1.2.0. "View version 1.2.0 details." opens the changes from the manifest; "View details" in the plugin list opens the description.
  5. When you start the update, the leader puts the URL back on read. The checksum gate downloads the ZIP, compares its SHA-256 with the manifest and hands the verified file to WordPress. Harden & Trim is now on 1.2.0 — and still deactivated.

The same applies on the command line. With wp plugin update jpkcom-harden-trim, WP-CLI updates the deactivated plugin as long as another JPKCom plugin with updater 1.5.0 is active and loaded. With --skip-plugins, on the other hand, no updater runs, the update row has no package, and WordPress refuses the update — exactly as intended.

How I test it

The updater is security code that has to behave the same in 19 plugins. Two things safeguard that.

A test suite of its own for the family. tests/test-updater-family.php runs without WordPress: the WordPress functions the updater needs are stubbed — site transients in memory, a small hook system with priorities, header parsing over real files, HTTP and download_url() over fixtures. A temporary plugins directory holds fake JPKCom plugins: in the standard layout, in renamed folders, with a foreign Plugin URI, as a Git checkout, and one in which an old updater still runs. The suite loads several copies of the updater at once by rewriting the namespace, and for the election tests the UPDATER_VERSION too.

For this article, I ran the suite again from Harden & Trim's v1.2.0 tag:

php tests/test-updater-family.php
#   170 passed, 0 failed

UPDATER_FILE=/path/to/updater-1.4.0.php php tests/test-updater-family.php
#   59 passed, 96 failed

The second run is the more important one. Against updater 1.4.0 from Harden & Trim 1.1.1, the suite has to fail — with named assertions, not with a crash. To that end, according to the suite's header comment, it intercepts every call 1.4.0 can't answer. A test suite that doesn't go red against the old code doesn't test the new behaviour.

A fingerprint against drift. In Harden & Trim, tests/test-debug-gate.php compares a normalised SHA-256 of its own updater file — namespace and slug factored out — with that of the reference copy from JPKCom Allow Block Types. If the copy differs, the test fails. The family suite itself is byte-identical in all 19 plugins.

What you need to do as a site owner

The good news: almost nothing, and most of it only once.

updater-1-5-checklist.md
# JPKCom plugins: switching to updater 1.5.0

- [ ] Every active JPKCom plugin updated once to its version with updater 1.5.0
- [ ] At least one of them stays active — then all deactivated ones get their updates too
- [ ] Multisite: that plugin is network-active or active on the main site
- [ ] Plugins in renamed folders (e.g. `jpkcom-…-main`) replaced with the release ZIP
- [ ] Git checkouts: intended? Then keep updating them with `git pull`

You don't need to touch deactivated plugins for this. Even one that is still on an old updater is found by the leader via its Plugin URI and offered the update — the old code inside doesn't matter, because it doesn't run anyway. It's different for active plugins with an old updater: they keep serving themselves, without the new protections. Only once all active ones are on 1.5.0 does the behaviour described in this article apply everywhere.

On multisite, a plugin that is only active on a subsite isn't enough: the network manages updates, and an updater has to run there. If none does, nothing is offered — but nothing unverified is installed either.

Limits and open points

  • No active JPKCom plugin, no updates. If all my plugins are deactivated, no updater runs. Everything then stays as it is until one of them is active again. That is the deliberate flip side of not loading any code of deactivated plugins.
  • Best effort when removing the URLs. Anyone who writes the transient straight into the database, bypassing the filters, gets around strip_packages(). The checksum gate still applies as soon as an updater from 1.5.0 on is loaded.
  • Checksum, not signature. Nothing has changed here since the making-of: SHA-256 shows that the ZIP matches the manifest, not who built it. How pipeline, checksum and manifest work together is described in the first article.
  • Other people's plugins stay out. The family only recognises plugins with my Plugin URI and a slug starting with jpkcom-. The pattern carries over to your own plugin collections; the class itself is tailored to mine.

FAQ

Do I need to install an additional plugin for this?

No. Every JPKCom plugin ships the updater. It's enough that one of them is active and on its version with updater 1.5.0.

Does WordPress now load the code of my deactivated plugins?

No. The leader only reads the plugin header of the deactivated plugins — with the same function, get_plugin_data(), that WordPress uses to show the plugin list. None of their code is executed.

What happens if two plugins have different updater versions?

The copy with the highest version becomes leader. Copies from 1.5.0 on fall in line. An active copy older than 1.5 keeps serving its own plugin; the leader writes no update row for it and only steps in at the checksum if the old copy supplies no package.

Why doesn't my plugin from the GitHub source ZIP get updated?

Because the folder is then called …-main and an update would delete it. Install the ZIP from the respective release instead. Its folder has the right name, and from then on updates run normally.

Does this work with WP-CLI too?

Yes. In WP-CLI the updater fetches manifests without a capability check, and wp plugin update also updates deactivated JPKCom plugins as long as another JPKCom plugin with updater 1.5.0 is loaded. The checksum is verified in exactly the same way.

Conclusion

The flaw in the old design wasn't a programming error but a consequence of where the updater lives: inside the plugin it is supposed to update. Updater 1.5.0 moves the responsibility from the single copy to the family. Any active plugin is enough, and it takes care of all the others, including the ones that nobody switches on any more.

The fact that an updater now offers packages for plugins whose code isn't even running has sharpened the security questions. That is why a stricter gate stands behind the family than before: no download URLs in the database, a checksum that is mandatory and also verifies packages supplied by others, and fetches only for accounts that may actually install updates, plus cron and WP-CLI. For you as a site owner, one task remains: bring every active plugin to its version with updater 1.5.0 once.

Further reading

How the pipeline of GitHub Actions, checksum and manifest on GitHub Pages is built is in the making-of on the auto-updates. What Harden & Trim itself can do, including the new WP-CLI command, is described in the guide to JPKCom Harden & Trim. The version history is in the Harden & Trim changelog, and the updater class itself is in the repository (opens in a new tab).