onstage2426 / wp-plugin-updater
Requires
- php: >=8.3
Requires (Dev)
- rector/rector: ^2.0
README
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:
- Latest GitHub release
- Latest version tag
- 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 |
2× |
assets/icon-128x128.png or .jpg |
1× |
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.mdCHANGES.mdchangelog.mdchanges.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; });