onstage2426/wp-plugin-updater

Maintainers

Package info

github.com/onstage2426/wp-plugin-updater

pkg:composer/onstage2426/wp-plugin-updater

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

v0.1.1 2026-06-06 20:15 UTC

This package is auto-updated.

Last update: 2026-06-06 20:18:03 UTC


README

Source: github.com/onstage2426/wp-plugin-updater

Delivers WordPress plugin updates from a private GitHub repository. Hooks into the standard WordPress update system so your plugin appears in the Plugins → Updates screen and can be installed with a single click.

Requirements

  • PHP 8.3+
  • WordPress 5.0+
  • Plugin hosted on GitHub (public or private)

Installation

composer require onstage2426/wp-plugin-updater

Quick start

use Onstage2426\PluginUpdater\Updater;

$updater = Updater::build(
    'https://github.com/your-org/your-repo',
    __FILE__
);

Call this at plugin load time — at the top level of your main plugin file, or in a very early action. The updater registers all its WP hooks internally.

Updater::build() — factory method

Updater::build(
    string $repositoryUrl,
    string $pluginFile,
    string $slug        = "",
    int    $checkPeriod = 12,
    string $optionName  = "",
    string $muPluginFile = "",
): static
Parameter Type Default Description
$repositoryUrl string Full GitHub URL, e.g. https://github.com/owner/repo
$pluginFile string Absolute path to the plugin's main PHP file. Pass __FILE__ from your plugin's main file
$slug string "" Unique identifier for this plugin. Defaults to the filename without .php. Must be unique across all active Updater instances
$checkPeriod int 12 How often to check for updates, in hours. Set to 0 to disable automatic checks entirely
$optionName string "" WP site option name used to persist check state between requests. Defaults to external_updates-{slug}
$muPluginFile string "" Only for MU-plugins. Relative filename of the MU-plugin as it appears in wp-content/mu-plugins/ (e.g. my-plugin.php). See MU-Plugins

Public properties

Once built, the following properties are available read-only on the $updater instance:

Property Type Description
$updater->slug string The plugin's unique slug
$updater->pluginFile string Plugin basename as returned by plugin_basename(), e.g. my-plugin/my-plugin.php
$updater->directoryName string Plugin directory name, e.g. my-plugin
$updater->muPluginFile string MU-plugin filename; empty string for regular plugins
$updater->updateState UpdateState Persisted check state. See UpdateState

Configuration methods

All methods return static and can be chained.

setBranch(string $branch): static

Sets which branch to use as the update source. Default is "master".

When the branch is "master" or "main", the updater tries strategies in order:

  1. Latest GitHub release
  2. Latest version tag
  3. Branch HEAD

For any other branch name, only the branch HEAD is used. In that case the release and tag strategies are skipped, and the version number must come from the GitHub release tag — without a release or tag on that branch, no update will be detected.

$updater->setBranch('main');

setReleaseFilter(callable $callback, int $releaseTypes, int $maxReleases): static

Applies a custom filter when picking the latest release. The updater fetches up to $maxReleases releases and calls $callback for each one to decide whether to use it.

setReleaseFilter(
    callable $callback,
    int      $releaseTypes = Updater::RELEASE_FILTER_SKIP_PRERELEASE,
    int      $maxReleases  = 20,
): static
Parameter Type Default Description
$callback callable Called as fn(string $version, object $release): bool. Return true to accept the release, false to skip it. $release is the raw GitHub API release object
$releaseTypes int RELEASE_FILTER_SKIP_PRERELEASE Updater::RELEASE_FILTER_ALL to include pre-releases, Updater::RELEASE_FILTER_SKIP_PRERELEASE to exclude them
$maxReleases int 20 How many recent releases to fetch and examine. Min 1, max 100
// Accept only releases whose version contains "-stable"
$updater->setReleaseFilter(
    fn(string $version) => str_contains($version, '-stable'),
    Updater::RELEASE_FILTER_ALL,
    50
);

Constants:

Constant Value Description
Updater::RELEASE_FILTER_ALL 3 Examine both stable releases and pre-releases
Updater::RELEASE_FILTER_SKIP_PRERELEASE 1 Skip releases marked as pre-release on GitHub

setReleaseVersionFilter(string $regex, int $releaseTypes, int $maxReleasesToExamine): static

Convenience wrapper around setReleaseFilter() for filtering by version number using a regular expression.

setReleaseVersionFilter(
    string $regex,
    int    $releaseTypes        = Updater::RELEASE_FILTER_SKIP_PRERELEASE,
    int    $maxReleasesToExamine = 20,
): static
// Only pick up releases whose version starts with "2."
$updater->setReleaseVersionFilter('/^2\./');

// Include pre-releases, look back up to 30 releases
$updater->setReleaseVersionFilter('/^2\./', Updater::RELEASE_FILTER_ALL, 30);

throttleRedundantChecks(bool $enable, int $hours): static

When enabled, the updater skips scheduled checks if an update is already sitting in the queue waiting to be installed. This avoids hammering the GitHub API when nothing has changed.

throttleRedundantChecks(
    bool $enable = true,
    int  $hours  = 72,
): static
Parameter Type Default Description
$enable bool true Pass false to disable throttling
$hours int 72 Once an update is found, suppress further checks for this many hours
$updater->throttleRedundantChecks();             // enable with default 72 hours
$updater->throttleRedundantChecks(hours: 48);    // enable with custom period
$updater->throttleRedundantChecks(false);        // disable

enableDebugMode(bool $enable): static

Controls whether errors and warnings are sent to the PHP error log. Defaults to the value of the WP_DEBUG constant.

$updater->enableDebugMode();        // enable
$updater->enableDebugMode(false);   // disable

removeHooks(): void

Removes all WordPress hooks registered by this updater instance. Called automatically on plugin uninstall. Call manually if you need to tear down the updater at runtime.

$updater->removeHooks();

Reading state

checkForUpdates(): ?Update

Forces an immediate update check against the GitHub API, regardless of the normal schedule. Updates updateState with the result. Returns an Update object if a newer version is available, null otherwise.

$update = $updater->checkForUpdates();
if ($update) {
    error_log("New version available: " . $update->version);
}

getUpdate(): ?Update

Reads the last known update from updateState without making any API calls. Returns an Update object only if the stored version is newer than what is currently installed. Returns null if up to date or if no check has been run yet.

$update = $updater->getUpdate();

getLastRequestApiErrors(): array

Returns API errors collected during the most recent checkForUpdates() call. Each entry is an associative array with keys error (WP_Error), httpResponse (raw WP HTTP response or null), and url (string|null).

$updater->checkForUpdates();

foreach ($updater->getLastRequestApiErrors() as $item) {
    $wpError = $item['error'];
    error_log($wpError->get_error_message() . ' [' . $item['url'] . ']');
}

getPluginTitle(): string

Returns the plugin's translated display name from its file header. Returns an empty string if the header cannot be read.

echo $updater->getPluginTitle();  // e.g. "My Plugin"

getUniqueName(string $baseTag): string

Generates a plugin-scoped hook name: pu_{baseTag}-{slug}. Used to namespace all filters and actions to this specific plugin instance.

$filterName = $updater->getUniqueName('request_info_result');
// returns e.g. "pu_request_info_result-my-plugin"

triggerError(string $message, int $errorType): void

Fires trigger_error() if debug mode is active. Use E_USER_WARNING or E_USER_ERROR as $errorType.

$updater->triggerError('Something went wrong.', E_USER_WARNING);

WordPress filters

All filter tags are scoped with $updater->getUniqueName('tag')pu_{tag}-{slug}.

pu_request_info_result-{slug}

Fired after the GitHub API is called to build the plugin info shown in the "View details" popup. Receives the populated PluginInfo object (or null on failure). Return a modified PluginInfo or null to suppress the popup.

add_filter($updater->getUniqueName('request_info_result'), function (?PluginInfo $info): ?PluginInfo {
    if ($info) {
        $info->sections['changelog'] = '<p>See GitHub releases for changelog.</p>';
    }
    return $info;
});

pu_request_update_result-{slug}

Fired after the GitHub API is called during a scheduled or manual check, before the result is written to state. Receives the Update object (or null). Return a modified Update or null to suppress the update.

add_filter($updater->getUniqueName('request_update_result'), function (?Update $update): ?Update {
    return $update;
});

pu_pre_inject_info-{slug}

Fired just before plugin info is returned to WordPress for the "View details" popup. Last chance to modify or suppress the info. Receives ?PluginInfo.

pu_pre_inject_update-{slug}

Fired just before an update is written into site_transient_update_plugins. Last chance to modify the Update object before WordPress sees it.

add_filter($updater->getUniqueName('pre_inject_update'), function (Update $update): Update {
    // e.g. override the download URL
    $update->download_url = 'https://example.com/my-plugin.zip';
    return $update;
});

pu_check_now-{slug}

Controls whether the scheduler should run a check right now. Receives bool $shouldCheck, the timestamp of the last check as int, and the configured check period in hours as int.

add_filter($updater->getUniqueName('check_now'), function (bool $should, int $lastCheck, int $period): bool {
    // Force a check if it's been more than a day regardless of $period
    return (time() - $lastCheck) > DAY_IN_SECONDS;
}, 10, 3);

pu_first_check_time-{slug}

Controls the timestamp of the first scheduled cron event. WordPress cron fires the first check at this time. Defaults to a random offset within the first checkPeriod window to spread load across installations.

add_filter($updater->getUniqueName('first_check_time'), function (int $timestamp): int {
    return time();  // check immediately on the next cron run
});

pu_vcs_update_detection_strategies-{slug}

Lets you reorder, replace, or extend the array of detection strategies passed to GitHubClient::chooseReference(). Each strategy is a callable that returns a reference object or null. Keys are the strategy name constants on GitHubClient.

use Onstage2426\PluginUpdater\GitHubClient;

add_filter($updater->getUniqueName('vcs_update_detection_strategies'), function (array $strategies): array {
    // Remove the tag strategy, only use releases
    unset($strategies[GitHubClient::STRATEGY_LATEST_TAG]);
    return $strategies;
});

Available strategy keys: GitHubClient::STRATEGY_LATEST_RELEASE, GitHubClient::STRATEGY_LATEST_TAG, GitHubClient::STRATEGY_BRANCH.

pu_remove_from_default_update_checks-{slug}

By default this plugin is excluded from the WP.org update payload (it has no WP.org entry). Return false to include it in the payload anyway.

add_filter($updater->getUniqueName('remove_from_default_update_checks'), '__return_false');

pu_retain_fields-{slug}

Controls which fields are copied from a PluginInfo or raw object into an Update when Update::fromObject() is called. Receives the default field name array.

add_filter('pu_retain_fields-my-plugin', function (array $fields): array {
    // add a custom field
    $fields[] = 'my_custom_field';
    return $fields;
});

pu_api_error (action)

Fired whenever the GitHub API returns an error (network failure or non-200 response). Receives WP_Error $error, the raw HTTP response, the request URL, and the plugin slug.

add_action('pu_api_error', function (WP_Error $error, mixed $response, ?string $url, ?string $slug): void {
    if ($slug === 'my-plugin') {
        error_log('[my-plugin updater] ' . $error->get_error_message());
    }
}, 10, 4);

Data classes

Update

The minimal record written to site_transient_update_plugins.

Property Type Source
$slug ?string Plugin slug
$version ?string Version string from the GitHub release/tag, e.g. "1.4.2"
$download_url ?string ZIP download URL
$homepage ?string Plugin URI from the plugin file header
$icons array Icon URLs loaded from the local assets/ directory
$filename ?string Plugin basename, set at injection time

PluginInfo

The full metadata object shown in the "View details" popup.

Property Type Source
$name ?string Plugin Name header
$slug ?string Plugin slug
$version ?string Version from GitHub release/tag
$homepage ?string Plugin URI header
$sections array Keyed sections shown in the popup. description comes from the plugin header; changelog comes from the GitHub release body or a local CHANGELOG.md / CHANGES.md file
$download_url ?string ZIP download URL
$banners mixed Banner images loaded from the local assets/ directory
$icons array Icon images loaded from the local assets/ directory
$author ?string Author header
$author_homepage ?string Author URI header
$downloaded ?int Download count from GitHub release assets (first asset only)
$last_updated ?string Release creation timestamp (created_at from GitHub)
$filename ?string Plugin basename

UpdateState

Persists the update check state to a WP site option. Accessible as $updater->updateState.

Method Returns Description
getUpdate() ?Update The last stored update record, or null
getLastCheck() int Unix timestamp of the last check, or 0 if never checked
timeSinceLastCheck() int Seconds since the last check

Local assets

Place assets in an assets/ subdirectory inside your plugin directory. The updater loads them automatically for the update popup.

Icons (shown on the Plugins screen and in the popup):

Filename Size slot
assets/icon.svg SVG (preferred)
assets/icon-256x256.png or .jpg
assets/icon-128x128.png or .jpg

Banners (shown at the top of the "View details" popup):

Filename Size slot
assets/banner-772x250.png or .jpg Standard
assets/banner-1544x500.png or .jpg High-DPI

Distribution — Composer, assets, and built files

The updater downloads whichever ZIP GitHub provides for a release. How you handle dependencies and build artifacts depends on your workflow.

Option A — Commit built files (simple): run composer install --no-dev and commit the vendor/ directory, commit compiled CSS/JS, and never commit node_modules/. The source ZIP GitHub generates will include everything.

Option B — GitHub Actions release pipeline (recommended): keep vendor/, node_modules/, and build output out of git. On each tag push, a workflow installs dependencies, runs the build, creates a clean ZIP, and uploads it as a release asset. The updater automatically prefers the first release asset over GitHub's source ZIP when one is present.

A minimal workflow (.github/workflows/release.yml):

on:
  push:
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v6
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
      - run: composer install --no-dev --optimize-autoloader
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci && npm run build
      - name: Create plugin ZIP
        run: |
          zip -r my-plugin.zip . \
            --exclude=".git/*" --exclude=".github/*" \
            --exclude="node_modules/*" --exclude="*.config.js" \
            --exclude="package*.json"
      - uses: softprops/action-gh-release@v3
        with:
          files: my-plugin.zip

If no build step is needed (PHP-only, no composer packages), the source ZIP is fine and no workflow is required.

Changelog

If the GitHub release has a body, it is used as the changelog in the popup. If not, the updater looks for a changelog file in your plugin directory:

  • CHANGELOG.md
  • CHANGES.md
  • changelog.md
  • changes.md

If Parsedown is installed, Markdown is rendered to HTML. Otherwise the raw text is shown.

MU-plugins

MU-plugins that live directly in wp-content/mu-plugins/ (not in a subdirectory) must pass the $muPluginFile parameter so WordPress can match the plugin correctly:

$updater = Updater::build(
    'https://github.com/your-org/your-repo',
    __FILE__,
    '',     // slug
    12,     // check period
    '',     // option name
    'my-mu-plugin.php',   // filename as it appears in mu-plugins/
);

MU-plugins installed in a subdirectory (e.g. mu-plugins/my-plugin/my-plugin.php) do not need this parameter.

Debugging

$updater->enableDebugMode();

This sends warnings to error_log() when the plugin file cannot be read, the version header is missing, or the API returns no usable version.

You can also inspect errors from the last check directly:

$update = $updater->checkForUpdates();

foreach ($updater->getLastRequestApiErrors() as $item) {
    error_log($item['error']->get_error_message() . '' . $item['url']);
}

Full example

use Onstage2426\PluginUpdater\Updater;
use Onstage2426\PluginUpdater\PluginInfo;

$updater = Updater::build(
    'https://github.com/acme/my-plugin',
    __FILE__,
    'my-plugin',
    24   // check every 24 hours
);

// Only ship stable 2.x releases
$updater
    ->setReleaseVersionFilter('/^2\./')
    ->throttleRedundantChecks(hours: 72)
    ->enableDebugMode();

// Append a note to the changelog in the popup
add_filter($updater->getUniqueName('request_info_result'), function (?PluginInfo $info): ?PluginInfo {
    if ($info && !empty($info->sections['changelog'])) {
        $info->sections['changelog'] .= '<p><a href="https://github.com/acme/my-plugin/releases">Full release history on GitHub</a></p>';
    }
    return $info;
});