Self-Hosted WordPress Plugin Updates
Drazen

Drazen

Published on: May 27th, 2024.

Self-Hosted WordPress Plugin Updates

So you developed your own plugin and now want to monetize on it. Since it's not free, you can't use the WordPress Plugin Repository for this purpose because it only supports free plugins. You will need to either host it on a marketplace or host it yourself. If you chose the latter and don't know how, then this guide is for you.

What will we be doing?

It takes a solid amount of effort, but it's not too complex. It basically boils down to two things:

  1. Client - Point your WordPress plugin to your own server for updates
  2. Server - Build & deploy an update server to handle said updates

The first point is rather simple. It takes a couple of hooks to modify your plugin in such a way that it points to a custom server for plugin updates.

The most effort lies in developing an update server which makes sense for you. This is also the part that is up to you on how to design it, since there is no single approach that will work for everyone. For the sake of simplicity, I have developed this as a WordPress plugin. In the past I also used an app built on the PERN stack. Anything goes.

The Client Plugin

I have created a simple plugin where everything is located in the main plugin file. Of course you can split this up into separate files, use classes, composer with autoloading, etc. But for simplicity's sake we will throw everything into one pot 🍲

Preparation

Before we start with the actual code, let's define some constants to make our life a little bit easer.

1/*
2Plugin Name: Self-Hosted WordPress Plugin Updates - Client
3Description: Demo plugin showcasing a client plugin which updates from a custom update server.
4Version: 1.0.0
5Author: Drazen Bebic
6Author URI: https://drazen.bebic.dev
7Text Domain: shwpuc
8Domain Path: /languages
9*/
10
11// Current plugin version.
12define( "SHWPUC_PLUGIN_VERSION", "1.0.0" );
13
14// Output of this will be
15// "self-hosted-plugin-updates/self-hosted-plugin-updates.php".
16define( "SHWPUC_PLUGIN_SLUG", plugin_basename( __FILE__ ) );
17
18// Set the server base URL. This should
19// be replaced with the actual URL of
20// your update server.
21define( "SHWPUC_API_BASE_URL", "https://example.com/wp-json/shwpus/v1" );
22
23/**
24 * Returns the plugin slug: self-hosted-plugin-updates
25 *
26 * @return string
27 */
28function shwpuc_get_plugin_slug() {
29	// We split this string because we need the
30	// slug without the fluff.
31	list ( $t1, $t2 ) = explode( '/', SHWPUC_PLUGIN_SLUG );
32
33	// This will remove the ".php" from the
34	// "self-hosted-plugin-updates.php" string
35	// and leave us with the slug only.
36	return str_replace( '.php', '', $t2 );
37}
38

We defined a new plugin, constants for the plugin version, slug, and the base URL of our update server. Another thing we added is a function to retrieve the plugin slug, without the ".php" ending.

Package download

The very first thing we want to do is to add a filter to the pre_set_site_transient_update_plugins hook. We will modify the response for our plugin so that it checks the remote server for a newer version.

1/**
2 * Add our self-hosted auto-update plugin
3 * to the filter transient.
4 *
5 * @param $transient
6 *
7 * @return object $transient
8 */
9function shwpuc_check_for_update( $transient ) {
10	// This will be "self-hosted-plugin-updates-client"
11	$slug = shwpuc_get_plugin_slug();
12
13	// Set the server base URL. This should be replaced
14	// with the actual URL of your update server.
15	$api_base = SHWPUC_API_BASE_URL;
16
17	// This needs to be obtained from the
18	// site settings. Somewhere set by a
19	// setting your plugin provides.
20	$license_i_surely_paid_for = 'XXX-YYY-ZZZ';
21
22	// Get the remote version.
23	$remote_version = shwpuc_get_remote_version( $slug );
24
25	// This is the URL the new plugin
26	// version will be downloaded from.
27	$download_url = "$api_base/package/$slug.$remote_version.zip?license=$license_i_surely_paid_for";
28
29	// If a newer version is available, add the update.
30	if ( $remote_version
31		&& version_compare( SHWPUC_PLUGIN_VERSION, $remote_version, '<' )
32	) {
33		$obj              = new stdClass();
34		$obj->slug        = $slug;
35		$obj->new_version = $remote_version;
36		$obj->url         = $download_url;
37		$obj->package     = $download_url;
38
39		$transient->response[ SHWPUC_PLUGIN_SLUG ] = $obj;
40	}
41
42	return $transient;
43}
44
45// Define the alternative API for updating checking
46add_filter( 'pre_set_site_transient_update_plugins', 'shwpuc_check_for_update' );
47

This function alone already does quite a lot of the heavy lifting, it...

  1. Retrieves the latest plugin version from the remote server.
  2. Checks if the remote version is greater than the currently installed version.
  3. Passes the license URL parameter to the download link.
  4. Stores update information into the transient if there is a newer version available.

Version Check

You probably noticed the shwpu_get_remote_version() function, so let's get into that now.

1/**
2 * Return the latest version of a plugin on
3 * the remote update server.
4 *
5 * @return string|null $remote_version
6 */
7function shwpuc_get_remote_version( $slug ) {
8	$api_base = SHWPUC_API_BASE_URL;
9	$license  = 'XXX-YYY-ZZZ';
10	$url      = "$api_base/version/$slug?license=$license";
11	$request  = wp_remote_get( $url );
12
13	if ( ! is_wp_error( $request )
14	     || wp_remote_retrieve_response_code( $request ) === 200
15	) {
16		return $request['body'];
17	}
18
19	return null;
20}
21

Pretty straightforward: Send the request and pass on the response.

Plugin Information

Now our plugin knows that there is a new version, but what about the "What's new?" section and the changelog for this new fancy-pants version? Gues what? We need another hook for this.

1/**
2 * Add our self-hosted description to the filter
3 *
4 * @param boolean  $false
5 * @param array    $action
6 * @param stdClass $arg
7 *
8 * @return bool|stdClass
9 */
10function shwpuc_check_info( $false, $action, $arg ) {
11	// This will be "self-hosted-plugin-updates"
12	$slug = shwpuc_get_plugin_slug();
13
14	// Abort early if this isn't our plugin.
15	if ( $arg->slug !== $slug ) {
16		return false;
17	}
18
19	// Set the server base URL. This should be replaced
20	// with the actual URL of your update server.
21	$api_base = SHWPUC_API_BASE_URL;
22	$license  = 'XXX-YYY-ZZZ';
23	$url      = "$api_base/info/$slug?license=$license";
24	$request  = wp_remote_get( $url );
25
26	if ( ! is_wp_error( $request )
27		|| wp_remote_retrieve_response_code( $request ) === 200
28	) {
29		return unserialize( $request['body'] );
30	}
31
32	return null;
33}
34
35// Define the alternative response for information checking
36add_filter( 'plugins_api', 'shwpuc_check_info', 10, 3 );
37

This hook will trigger when you go to check the changelog of the newly available update for your plugin. Your update server needs to return information about the new version, like the description, changelog, and anything else you think is important to know.

The Update Server

Now that we covered the basics about what the client plugin should do, let's do the same for the update server. Like I said before, this part leaves a lot more room for interpretation, becasue it is a 3rd party application which you can design and run on anything you want. You only need to make sure that the response is compatible with WordPress.

For this demo, I decided to use a simple WordPress plugin which you would install on a regular WordPress instance. This WordPress instance will then act as your plugin update server.

Important: The client and server plugin will not work on the same WordPress instance! When the client tries to perform the update, it will automatically turn on maintenance mode on the WordPress instance, which disables the REST API, which makes the download of the new package version fail.

API routes

This server plugin will have to provide a handful of API routes which we have previously mentioned in the client plugin, and those are:

  1. /v1/version/:plugin - Used to check the latest version of the plugin.
  2. /v1/info/:plugin - Used to check the information about the latest version of the plugin.
  3. /v1/package/:plugin - Used to download the latest version of the plugin.

Registering the routes

The very first thing you need to do is to register the necessary REST API routes with WordPress. We will register one route for every endpoint mentioned previously. Pretty straightforward:

1/**
2 * Registers the routes needed by the plugins.
3 *
4 * @return void
5 */
6function shwpus_register_routes() {
7	register_rest_route(
8		'shwpus/v1',
9		'/version/(?P<plugin>[\w-]+)',
10		array(
11			array(
12				'methods'             => WP_REST_Server::READABLE,
13				'callback'            => 'shwpus_handle_plugin_version_request',
14				'permission_callback' => 'shwpus_handle_permission_callback',
15				'args'                => array(
16					'plugin' => array(
17						'description' => 'The plugin slug, i.e. "my-plugin"',
18						'type'        => 'string',
19					),
20				),
21			),
22		)
23	);
24
25	register_rest_route(
26		'shwpus/v1',
27		'/info/(?P<plugin>[\w-]+)',
28		array(
29			array(
30				'methods'             => WP_REST_Server::READABLE,
31				'callback'            => 'shwpus_handle_plugin_info_request',
32				'permission_callback' => 'shwpus_handle_permission_callback',
33				'args'                => array(
34					'plugin' => array(
35						'description' => 'The plugin slug, i.e. "my-plugin"',
36						'type'        => 'string',
37					),
38				),
39			),
40		)
41	);
42
43	register_rest_route(
44		'shwpus/v1',
45		'/package/(?P<plugin>[\w.-]+)',
46		array(
47			array(
48				'methods'             => WP_REST_Server::READABLE,
49				'callback'            => 'shwpus_handle_plugin_package_request',
50				'permission_callback' => 'shwpus_handle_permission_callback',
51				'args'                => array(
52					'plugin' => array(
53						'description' => 'The plugin slug with the version, ending in .zip, i.e. "my-plugin.2.0.0.zip"',
54						'type'        => 'string',
55					),
56				),
57			),
58		)
59	);
60}
61

Permission Callback

You'll notice that I set the permission_callback to shwpus_handle_permission_callback. This function checks whether the license your client passed along is valid, so you know that the client is actually authorized for future updates.

You could also remove this check for the version and info routes, so that everyone gets notified about new version and knows what's new, but only the customers with valid licenses can actually update. To do this simply set the permission_callback to __return_true, which is a WordPress utility function which returns true right away.

Here's how our permission callback function looks like:

1/**
2 * @param WP_REST_Request $request
3 *
4 * @return true|WP_Error
5 */
6function shwpus_handle_permission_callback( $request ) {
7	$slug = $request->get_param( 'plugin' );
8	$license = $request->get_param( 'license' );
9
10	if ( $license !== 'XXX-YYY-ZZZ' ) {
11		return new WP_Error(
12			401,
13			'Invalid license',
14			array(
15				'slug' => $slug,
16				'license' => $license
17			)
18		);
19	}
20
21	return true;
22}
23

Check the version

This route fetches the latest version of the given plugin from your databse or whatever else you have. It needs to return it as text/html with nothing but the version number as a response.

1/**
2 * Finds the latest version for a given plugin.
3 *
4 * @param WP_REST_Request $request
5 *
6 * @return void
7 */
8function shwpus_handle_plugin_version_request( $request ) {
9	// Retrieve the plugin slug from the
10	// request. Use this slug to find the
11	// latest version of your plugin.
12	$slug = $request->get_param( 'plugin' );
13
14	// This is hardcoded for demo purposes.
15	// Normally you would fetch this from
16	// your database or whatever other
17	// source of truth you have.
18	$version = '1.0.1';
19
20	header('Content-Type: text/html; charset=utf-8');
21	echo $version;
22	die();
23}
24

After you've done that, your plugin should be able to tell you that there's a new version.

New Version Update Available

Plugin Information

This is where it gets interesting. This route needs to return the plugin information in a specific structure as a serialized PHP object. If you're using Node.js don't worry - there is a nifty npm package called php-serialize which will let you do just that.

Since we're using PHP, there's no need for that and we can just call the PHP native serialize() function.

1/**
2 * Fetches information about the latest version
3 * of the plugin with the given slug.
4 *
5 * @param WP_REST_Request $request
6 *
7 * @return void
8 */
9function shwpus_handle_plugin_info_request( $request ) {
10	$slug    = $request->get_param( 'plugin' );
11	$version = '1.0.1';
12
13	// This data should be fetched dynamically
14	// but for demo purposes it is hardcoded.
15	$info = new stdClass();
16	$info->name = 'Self-Hosted WordPress Plugin Updates - Client';
17	$info->slug = 'self-hosted-plugin-updates-client';
18	$info->plugin_name = 'self-hosted-plugin-updates-client';
19	$info->new_version = $version;
20	$info->requires = '6.0';
21	$info->tested = '6.5.3';
22	$info->downloaded = 12540;
23	$info->last_updated = '2024-05-23';
24	$info->sections = array(
25		'description' => '
26			<h1>Self-Hosted WordPress Plugin Updates - Client</h1>
27			<p>
28				Demo plugin showcasing a client plugin
29				which updates from a custom update
30				server.
31			</p>
32		',
33		'changelog' => '
34			<h1>We did exactly 3 things!</h1>
35			<p>
36				You thought this is going to be a huge update.
37				But it\'s not. Sad face.
38			</p>
39			<ul>
40				<li>Added a cool new feature</li>
41				<li>Added another cool new feature</li>
42				<li>Fixed an old feature</li>
43			</ul>
44		',
45		// You can add more sections this way.
46		'new_tab' => '
47			<h1>Woah!</h1>
48			<p>We are so cool, we know how to add a new tab.</p>
49		',
50	);
51	$info->url = 'https://drazen.bebic.dev';
52	$info->download_link = get_rest_url( null, "/shwpus/v1/package/$slug.$version.zip" );
53
54	header('Content-Type: text/html; charset=utf-8');
55	http_response_code( 200 );
56	echo serialize( $info );
57	die();
58}
59

This should make your changes in the frontend visible.

Plugin Information Window

Package Download

This is where your plugin will be downloaded from. For demo purposes I simply put the plugin .zip files in a packages directory which I put into wp-content. You can of course integrate whatever other file storage you have and fetch your plugin zips from there.

1/**
2 * @param WP_REST_Request $request
3 *
4 * @return void
5 */
6function shwpus_handle_plugin_package_request( $request ) {
7	// Contains the plugin name, version, and .zip
8	// extension. Example:
9	// self-hosted-plugin-updates-server.1.0.1.zip
10	$plugin = $request->get_param( 'plugin' );
11
12	// The packages are located in wp-content for
13	// demo purposes.
14	$file = WP_CONTENT_DIR . "/packages/$plugin";
15
16	if ( ! file_exists( $file ) ) {
17		header( 'Content-Type: text/plain' );
18		http_response_code( 404 );
19		echo "The file $file does not exist.";
20		die();
21	}
22
23	$file_size = filesize( $file );
24
25	header( 'Content-Type: application/octet-stream' );
26	header( "Content-Length: $file_size" );
27	header( "Content-Disposition: attachment; filename=\"$plugin\"" );
28	header( 'Access-Control-Allow-Origin: *' );
29	http_response_code( 200 );
30	readfile( $file );
31	die();
32}
33

And last but not least, your plugin can now be fully updated!

Plugin Update Complete

Conclusion

Hosting your own plugin update server is very much doable. The complexity increases with your requirements for the "backend" administration. If you need a UI then you will need to expand on the server part quite a lot.

The client part is pretty easy and straightforward, there's not much that you need to do except add a few hooks. You could go a step further and disable the plugin if there is no valid license present.

Resources

I added the source code for these two plugins into two comprehensive GitHub gists.

  1. Client Plugin
  2. Server Plugin