A comprehensive WordPress plugin updater library that enables automatic updates, license verification, and remote plugin management for custom WordPress plugins.
- Automatic Plugin Updates: Seamlessly check for and install plugin updates from your remote server
- License Management: Built-in license key verification and validation system
- Admin Interface: Clean WordPress admin interface for license management
- Plugin Tracking: Track plugin activation, deactivation, and usage statistics
- WordPress Integration: Hooks into WordPress core update system
- Flexible Configuration: Customizable API endpoints, menu placement, and licensing options
- WordPress 5.0 or higher
- PHP 7.4 or higher
- Valid API server endpoint for plugin updates and license verification
composer require shazzad/plugin-updaterSince 2.0.0 (2026-08-19) the library ships two namespaces side by side:
Shazzad\PluginUpdater\V2(src/V2/) — active. New consumers should use this. Config-array constructor, license admin notices, and an explanation line in the plugins-list update row. Requirescomposer require shazzad/plugin-updater:^2.0.Shazzad\PluginUpdater(src/) — V1, frozen: critical fixes only. Existing plugins keep working unchanged and opt in to V2 deliberately by bumping to^2.0and switching the namespace. V2 uses the same option keys, transients, and cron hooks as V1, so a plugin moving V1→V2 keeps every saved license.
The two majors never share classes, so plugins on different library versions coexist on one site without the first-loader-wins fatal V1 was exposed to.
<?php
// Guarded with class_exists() so a build that's missing the library degrades
// to "no license/update UI" instead of a fatal error on every request.
if ( class_exists( \Shazzad\PluginUpdater\V2\Integration::class ) ) {
new \Shazzad\PluginUpdater\V2\Integration( [
'api_url' => 'https://your-api-server.com/api',
'file' => __FILE__, // or plugin_basename( __FILE__ ) — both accepted
'product_uid' => 'prod_xxxxxxxxxxxxxxxxxxxx', // preferred identity
'product_id' => '12', // legacy identity; needed to reach id-keyed licenses
'license' => true, // false = update checks only
'menu' => [ // omit for defaults; false hides the page
'label' => 'My Plugin License',
'parent' => 'plugins.php',
'priority' => 10,
],
'meta' => [ 'memory_limit' => ini_get( 'memory_limit' ) ], // optional
] );
}Unknown config keys, a missing api_url/file, a non-callable meta_callback, or
product_uid without product_id trigger _doing_it_wrong() in debug mode — construction
always proceeds. setMeta(), setMetaCallback(), and setProductUid() are still available
as chainable setters. The shazzad-plugin-updater-test plugin is a working V2 example
(product_id 99, license on, custom menu label, setMetaCallback() + setMeta() chained).
<?php
// Initialize the updater (autoloaded via Composer). Guarded with class_exists()
// so a build that's missing the library degrades to "no license/update UI"
// instead of a fatal error on every request.
if ( class_exists( \Shazzad\PluginUpdater\Integration::class ) ) {
new \Shazzad\PluginUpdater\Integration(
'https://your-api-server.com/api', // API URL
plugin_basename( __FILE__ ), // Plugin file path
'your-product-id', // Product ID
true, // Enable licensing
true, // Display admin menu
'My Plugin License', // Menu label
'plugins.php', // Parent menu
10 // Menu priority
);
}/src/ # V1 — namespace Shazzad\PluginUpdater — frozen
├── Integration.php # Core state, license helpers, and subsystem wiring
├── Client.php # API client with typed methods (ping, check_license, updates, details)
├── Updater.php # Update checks and WordPress integration
├── Admin.php # License admin page
├── Tracker.php # Plugin tracking and license sync
└── V2/ # V2 — namespace Shazzad\PluginUpdater\V2 — active
├── Integration.php # Config-array entry point and subsystem wiring
├── Client.php # API client (same methods as V1)
├── Updater.php # Update checks and WordPress integration
├── Tracker.php # Plugin tracking and license sync
├── License/Store.php # Option/transient keys, uid-keyed storage, legacy-key migration
└── Admin/
├── LicensePage.php # License admin page
├── Notices.php # Dismissible "enter license" / "license expired" notices
└── UpdateMessage.php # Explanation line in the plugins-list update row
| Key | Type | Default | Description |
|---|---|---|---|
api_url |
string | - | Required. Your API server URL |
file |
string | - | Required. Plugin main file — __FILE__ or plugin_basename( __FILE__ ) |
product_uid |
string | '' |
Opaque prod_… uid on the server. Preferred identity |
product_id |
string | '' |
Numeric product id. Legacy identity; required to reach licenses stored under id-based keys |
license |
bool | false |
Enable license verification features |
menu |
array|false | [] |
License page settings: parent (defaults to plugins.php), label, priority (9999). false hides the page |
meta |
array | [] |
Static ping metadata — same as setMeta() |
meta_callback |
callable | null |
Builds ping metadata at ping time — same as setMetaCallback() |
| Parameter | Type | Default | Description |
|---|---|---|---|
$api_url |
string | - | Required. Your API server URL |
$product_file |
string | - | Required. Plugin file path (e.g., "my-plugin/my-plugin.php") |
$product_id |
string | - | Required. Unique product identifier |
$license_enabled |
bool | false |
Enable license verification features |
$display_menu |
bool | true |
Show license settings in WordPress admin |
$menu_label |
string | '' |
Custom label for admin menu item |
$menu_parent |
string | '' |
Parent menu slug (defaults to 'plugins.php') |
$menu_priority |
int | 9999 |
Menu display priority |
new \Shazzad\PluginUpdater\Integration(
'https://api.example.com',
plugin_basename( __FILE__ ),
'my-plugin-id'
);( new \Shazzad\PluginUpdater\Integration(
'https://api.example.com',
plugin_basename( __FILE__ ),
'my-plugin-id',
true, // Enable licensing
true, // Show admin menu
'My Plugin Updates', // Menu label
'tools.php', // Under Tools menu
20 // Menu priority
) )->setMeta( [
'theme' => function () { return get_stylesheet(); },
] );Your API server should provide the following endpoints:
GET /products/{product_id}/updates
Response:
{
"updates": {
"new_version": "2.1.0",
"package": "https://download-url.com/plugin.zip",
"url": "https://plugin-info-url.com",
"tested": "6.4",
"requires": "5.0",
"changelog": "Bug fixes and improvements"
}
}GET /products/{product_id}/details
Response:
{
"details": {
"name": "My Plugin",
"version": "2.1.0",
"author": "Developer Name",
"homepage": "https://plugin-website.com",
"sections": {
"description": "Plugin description",
"changelog": "Version history",
"installation": "Installation instructions"
},
"download_link": "https://download-url.com/plugin.zip"
}
}GET /products/{product_id}/check_license?license=LICENSE_KEY
Response:
{
"license": {
"status": "active",
"expires": "2024-12-31",
"customer_name": "John Doe",
"customer_email": "john@example.com",
"renewal_url": "https://example.com/renew?license={license_code}&email={email}"
}
}The renewal_url field is optional in the license verification response. When present and the license status is expired, a renewal link is displayed on the admin license page. The URL supports two placeholders that are replaced automatically:
{license_code}— replaced with the stored license key{email}— replaced with thebuyer_emailfrom the license data
A static URL without placeholders (e.g., https://example.com/renew) is also supported.
POST /products/{product_id}/ping
Used for tracking plugin installations and status. Sends site environment data and optional custom metadata.
Request body:
product_version: Current plugin versionproduct_status: Plugin status (active/inactive)wp_url: WordPress site URLwp_locale: WordPress localewp_version: WordPress versionadmin_email: Site admin emailadmin_name: First admin user's display namephp_version: PHP version of the serverdb_version: Database server version (e.g.8.0.36or10.11.6-MariaDB)server_software: Web server software (e.g.nginx/1.24.0,Apache/2.4.58 (Ubuntu))license: The stored license key, when licensing is enabled and a key is saved (lets the server bind the install to its license)meta: Optional key-value pairs of custom metadata
You can attach custom metadata to pings using setMeta(). Values can be static or closures — closures are resolved at ping time so data is always fresh.
( new \Shazzad\PluginUpdater\Integration(
'https://api.example.com',
plugin_basename( __FILE__ ),
'my-plugin-id'
) )->setMeta( [
'theme' => function () { return get_stylesheet(); },
'memory_limit' => ini_get( 'memory_limit' ),
'active_plugins_count' => function () {
return count( get_option( 'active_plugins' ) );
},
] );Alternatively, setMetaCallback() accepts a single closure that builds the whole metadata array at once — it runs fresh at every ping. Both methods are chainable and can be combined:
( new \Shazzad\PluginUpdater\Integration(
'https://api.example.com',
plugin_basename( __FILE__ ),
'my-plugin-id'
) )->setMeta( [
'environment' => 'production',
'channel' => 'direct',
] )->setMetaCallback( function () {
return [
'memory_limit' => ini_get( 'memory_limit' ),
'theme' => get_stylesheet(),
'plugin_count' => count( get_option( 'active_plugins', [] ) ),
];
} );- Static values (strings, numbers) are sent as-is
- Closures are called at each ping and the return value is sent. In V1 only
Closureinstances are resolved (forsetMetaCallback()too). In V2 the callback may be any callable, andmetavalues that are Closures or array-callables are resolved — plain strings always stay data even when they happen to name a function - When both are used, the
setMetaCallback()array is built first andsetMeta()entries are merged over it — on a key conflict,setMeta()wins - Metadata is synced on every ping — keys removed from
setMeta()are deleted from the server - The site admin name and email are always sent automatically as top-level ping fields (
admin_name,admin_email) — no metadata entries needed for those - The server environment is also reported automatically as top-level ping fields (
php_version,db_version,server_software) — do not duplicate these in metadata
In V2 the uid is simply the product_uid config key (see above). In V1, multiple plugins may bundle this library as a dependency, and the oldest loaded copy wins the class_exists() race — the setProductUid() method may not exist in the loaded class. Use a guard to detect it, then call it to set the opaque product uid (format: prod_…). When set, API requests address the product by uid instead of the enumerable numeric id, and licenses are stored under uid-based option keys; when unset, numeric product_id behavior is unchanged. On first call (or on V2 construction with a product_uid), existing id-based licenses are automatically cloned to uid-based keys; old copies are retained for backward compatibility until a future prune release (tracked as issue #24; it will ship in V2, never in the frozen V1 namespace).
$integration = new \Shazzad\PluginUpdater\Integration( $api_url, $basename, 6, true );
if ( method_exists( $integration, 'setProductUid' ) ) {
$integration->setProductUid( 'prod_xxxxxxxxxxxxxxxxxxxx' );
}API requests to updates, details, and check_license include these query parameters:
license: License key (if licensing enabled)
The updater integrates with WordPress using these hooks:
pre_set_site_transient_update_plugins: Inject update informationplugins_api: Provide plugin details for update screenupgrader_package_options: Configure upgrade processupgrader_process_complete: Handle post-update cleanupload-update-core.php: Clear the cached API responses so "Check again" fetches fresh data- Plugin activation/deactivation hooks for tracking
- V2 only:
admin_notices/admin_init(license notices and their snooze) andin_plugin_update_message-{file}(update-row explanation)
- License Sync: Hourly cron job to verify license status
- Update Checks: Integrated with WordPress core update system
When licensing is enabled, the updater adds an admin page with:
- License key input field
- License status display
- Update availability notifications
- Direct upgrade buttons
- Changelog and upgrade notices
V2 additionally shows, to users with the update_plugins capability, a dismissible admin notice when no license key is saved or the license has expired (linking renewal_url), snoozable for one week per product and notice type, plus an explanation line inside the plugin's update row on the Plugins screen when the update package is withheld.
By default, the license page appears under the Plugins menu; an empty parent falls back
to plugins.php. The page is always a submenu (add_submenu_page()) — there is no top-level
option. To customise the parent:
// V2
'menu' => [ 'parent' => 'tools.php' ] // Under Tools
'menu' => [ 'parent' => 'options-general.php' ] // Under Settings
'menu' => false // No license page at all
// V1: pass the parent slug as the 7th constructor argument ($menu_parent)- Input Sanitization: All user inputs are properly sanitized
- Nonce Verification: WordPress nonces protect admin forms
- Capability Checks: Requires
delete_userscapability for license management - XSS Protection: Output is escaped using WordPress functions
The updater includes comprehensive error handling:
- API connection failures
- Invalid license keys
- Update server timeouts
- Malformed responses
Errors are returned as WP_Error from the Client methods and surfaced on the license admin page; the library does not write to the PHP error log.
See CHANGELOG.md for the full release history.
For support and bug reports, please contact your plugin developer or visit the plugin's official support channels.
This updater package is typically licensed under the same terms as your main plugin. Check your plugin's license file for specific terms.