Plugins on WordPress.org get update notifications, one-click updates and automatic updates for free. A plugin you sell from your own site gets none of that by default: WordPress does not know where to look. This lesson shows the mechanics WordPress gives you to plug in your own update source, how licence checks fit around them, and what the GPL means for a commercial plugin.
How WordPress checks for updates
Twice a day, and when an administrator visits certain screens, wp_update_plugins() sends the list of installed plugins to the WordPress.org API. The answer is stored in the update_plugins site transient: an object whose response property lists plugins with updates, and whose no_update property lists plugins that are current. The Plugins screen, the updates screen, automatic updates and WP-CLI all read that transient.
So delivering your own updates comes down to three things:
- Stop WordPress.org from offering a different plugin's update for your slug.
- Add your plugin's update information to the transient.
- Answer the "View details" request with information about your release.
The Update URI header
If your plugin's folder name matches a plugin on WordPress.org, an update for that other plugin could overwrite yours. The Update URI header prevents that:
<?php
/**
* Plugin Name: My Plugin
* Version: 1.4.0
* Requires at least: 6.5
* Requires PHP: 8.1
* Text Domain: myplugin
* Update URI: https://updates.example.com/myplugin
* License: GPL-2.0-or-later
*/
WordPress only applies WordPress.org updates to plugins whose Update URI is empty or points at WordPress.org. Any other value tells core that updates come from somewhere else. The hostname of the URI also names a filter, update_plugins_{$hostname}, here update_plugins_updates.example.com, which current versions of WordPress offer as a dedicated hook for supplying the update data. This lesson uses the older and more widely documented transient filter, which works the same way in principle.
Injecting the update
Filter the transient as it is saved with pre_set_site_transient_update_plugins. The update data comes from your server and is cached, so the remote request happens at most a few times a day:
<?php
// myplugin/src/Updates/Updater.php
namespace MyPlugin\Updates;
use MyPlugin\Contracts\Hookable;
defined( 'ABSPATH' ) || exit;
final class Updater implements Hookable {
private const API_URL = 'https://updates.example.com/v1/plugins/myplugin';
private const CACHE_KEY = 'myplugin_update_info';
private const SLUG = 'myplugin';
public function __construct( private string $plugin_file ) {}
public function register(): void {
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update' ) );
add_filter( 'plugins_api', array( $this, 'plugin_information' ), 10, 3 );
}
public function inject_update( $transient ) {
if ( ! is_object( $transient ) ) {
return $transient;
}
$remote = $this->remote_info();
if ( null === $remote ) {
return $transient;
}
$basename = plugin_basename( $this->plugin_file );
$item = (object) array(
'id' => 'https://updates.example.com/myplugin',
'slug' => self::SLUG,
'plugin' => $basename,
'new_version' => $remote['version'],
'url' => $remote['homepage'],
'package' => $remote['download_url'],
'requires' => $remote['requires'],
'requires_php' => $remote['requires_php'],
'tested' => $remote['tested'],
);
if ( version_compare( MYPLUGIN_VERSION, $remote['version'], '<' ) ) {
$transient->response[ $basename ] = $item;
} else {
$transient->no_update[ $basename ] = $item;
}
return $transient;
}
/**
* Release information from the update server, cached for 12 hours.
*/
private function remote_info(): ?array {
$cached = get_site_transient( self::CACHE_KEY );
if ( is_array( $cached ) ) {
return empty( $cached['version'] ) ? null : $cached;
}
$response = wp_remote_post(
self::API_URL,
array(
'timeout' => 10,
'body' => array(
'licence' => (string) get_site_option( 'myplugin_licence_key', '' ),
'site' => home_url(),
'version' => MYPLUGIN_VERSION,
),
)
);
$data = null;
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
if ( is_array( $decoded ) && isset( $decoded['version'], $decoded['download_url'] ) ) {
$data = array(
'version' => (string) $decoded['version'],
'download_url' => esc_url_raw( (string) $decoded['download_url'] ),
'homepage' => esc_url_raw( (string) ( $decoded['homepage'] ?? '' ) ),
'requires' => (string) ( $decoded['requires'] ?? '' ),
'requires_php' => (string) ( $decoded['requires_php'] ?? '' ),
'tested' => (string) ( $decoded['tested'] ?? '' ),
'changelog' => (string) ( $decoded['changelog'] ?? '' ),
);
}
}
// Cache failures briefly too, so a down server does not slow every admin page.
set_site_transient(
self::CACHE_KEY,
$data ?? array( 'version' => null ),
$data ? 12 * HOUR_IN_SECONDS : HOUR_IN_SECONDS
);
return $data;
}
}
Register it from Plugin::register() with the main file path, for example new Updates\Updater( MYPLUGIN_PATH . 'myplugin.php' ). It must run on every request type that can check for updates, including cron and WP-CLI, not only in the admin.
The item fields match what WordPress.org returns:
| Field | Purpose |
|---|---|
slug | Folder name, used by plugins_api and in links |
plugin | The basename, myplugin/myplugin.php, the key in response |
new_version | The version on offer |
package | The ZIP URL WordPress downloads and installs |
requires, requires_php, tested | Compatibility, shown on the updates screen |
url | Your plugin's home page |
Listing the plugin in no_update when it is current lets the auto-updates column on the Plugins screen work for your plugin too.
The response is untrusted input, even from your own server. The code checks the status code, validates the decoded JSON and cleans URLs before storing them. Always use HTTPS and leave certificate verification on, which is the default for wp_remote_post().
The "View details" modal
The update notice links to "View details", which calls the plugins_api() function with the action plugin_information. By default it asks WordPress.org. Answer it yourself for your slug:
<?php
// myplugin/src/Updates/Updater.php (inside the class)
public function plugin_information( $result, string $action, $args ) {
if ( 'plugin_information' !== $action || self::SLUG !== ( $args->slug ?? '' ) ) {
return $result;
}
$remote = $this->remote_info();
if ( null === $remote ) {
return $result;
}
return (object) array(
'name' => 'My Plugin',
'slug' => self::SLUG,
'version' => $remote['version'],
'requires' => $remote['requires'],
'requires_php' => $remote['requires_php'],
'tested' => $remote['tested'],
'homepage' => $remote['homepage'],
'download_link' => $remote['download_url'],
'sections' => array(
'changelog' => wp_kses_post( $remote['changelog'] ),
),
);
}
Returning $result unchanged for every other slug is essential, or you break the details modal for all other plugins. Sections are HTML displayed in the admin, so filter them with wp_kses_post().
Licences: what to gate
A licence key identifies a customer. The update server checks it before returning a download URL. Common rules:
- Gate updates and support, not the code that is already installed. If a licence expires, the plugin keeps working; the customer stops receiving updates. Switching off features on an expired licence breaks customers' sites and generates support load out of proportion to the revenue it protects.
- Make download URLs short-lived. The server returns a signed URL that expires after a few minutes, so a URL copied from one site cannot be shared for ever.
- Count activations on the server. The update request includes
home_url(), so the server can limit how many sites use a key and let customers move a licence between sites. - Never make the site depend on your server. If the licence server is down, the update check fails quietly, as in the code above. Never run licence checks on front-end page loads.
- Store the key carefully. Save it with
update_site_option(), so it is network-wide on Multisite, behind a settings form with a nonce and a capability check. Never print it in full in the admin.
Many teams use a licensing and update service for WordPress products, or a self-hosted update server, rather than writing the server side from scratch. Either way, the plugin side is what you have just seen.
Disclosure and consent
Every update check sends data to your server: the site URL, plugin version and licence key. Say so in your documentation and in the privacy policy text from lesson 22. Send only what you need. Sending usage statistics or anything about the site's users needs explicit opt-in consent.
The GPL and selling plugins
WordPress is licensed under the GPL, version 2 or later. A plugin that runs inside WordPress, calls its functions and hooks into it is generally treated as a derivative work, and WordPress.org's position is that plugin PHP code must be GPL-compatible. In practice:
- You may charge for your plugin. The GPL allows selling software. Customers pay for the download, updates, support and convenience.
- Customers may share the code. Anyone who receives your plugin can modify and redistribute it under the GPL. You cannot forbid that with a licence agreement, an obfuscator or a licence check.
- Licence keys are not copy protection. They control access to your services: your update server and your support desk. Someone redistributing the code does not get your updates.
- Bundled libraries must be GPL-compatible too, as lesson 17 noted.
- Include the licence. Declare it in the header, as above, and ship a licence file.
Your trademark is separate from the code licence. Others may redistribute the code, but not under your plugin's name and brand.
Free and premium together
A common model is a free plugin on WordPress.org and a premium add-on sold separately. The add-on declares Requires Plugins: myplugin from lesson 15, uses this lesson's updater, and extends the free plugin through its hooks. The free plugin must stay fully functional on its own: WordPress.org does not host trialware or plugins that lock features behind a payment, which the next lesson covers.
Summary
- WordPress stores update information in the
update_pluginssite transient; add your plugin to it withpre_set_site_transient_update_plugins. - Set an
Update URIheader so WordPress.org updates never overwrite your plugin; current versions also offerupdate_plugins_{$hostname}. - Answer
plugins_apifor your slug only, and return the original result for every other plugin. - Cache remote checks, fail quietly, validate responses and use HTTPS.
- Licences gate updates and support, not installed functionality; the GPL lets you sell the plugin but not stop customers sharing the code.