DEV Community

Cover image for Self-Hosted WordPress Plugin Updates
Drazen Bebic
Drazen Bebic Subscriber

Posted on • Edited on • Originally published at drazen.bebic.dev

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 easier.

/*
Plugin Name: Self-Hosted WordPress Plugin Updates - Client
Description: Demo plugin showcasing a client plugin which updates from a custom update server.
Version: 1.0.0
Author: Drazen Bebic
Author URI: https://drazen.bebic.dev
Text Domain: shwpuc
Domain Path: /languages
*/

// Current plugin version.
define( "SHWPUC_PLUGIN_VERSION", "1.0.0" );

// Output of this will be
// "self-hosted-plugin-updates/self-hosted-plugin-updates.php".
define( "SHWPUC_PLUGIN_SLUG", plugin_basename( __FILE__ ) );

// Set the server base URL. This should
// be replaced with the actual URL of
// your update server.
define( "SHWPUC_API_BASE_URL", "https://example.com/wp-json/shwpus/v1" );

/**
 * Returns the plugin slug: self-hosted-plugin-updates
 *
 * @return string
 */
function shwpuc_get_plugin_slug() {
    // We split this string because we need the
    // slug without the fluff.
    list ( $t1, $t2 ) = explode( '/', SHWPUC_PLUGIN_SLUG );

    // This will remove the ".php" from the
    // "self-hosted-plugin-updates.php" string
    // and leave us with the slug only.
    return str_replace( '.php', '', $t2 );
}
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * Add our self-hosted auto-update plugin
 * to the filter transient.
 *
 * @param $transient
 *
 * @return object $transient
 */
function shwpuc_check_for_update( $transient ) {
    // This will be "self-hosted-plugin-updates-client"
    $slug = shwpuc_get_plugin_slug();

    // Set the server base URL. This should be replaced
    // with the actual URL of your update server.
    $api_base = SHWPUC_API_BASE_URL;

    // This needs to be obtained from the
    // site settings. Somewhere set by a
    // setting your plugin provides.
    $license_i_surely_paid_for = 'XXX-YYY-ZZZ';

    // Get the remote version.
    $remote_version = shwpuc_get_remote_version( $slug );

    // This is the URL the new plugin
    // version will be downloaded from.
    $download_url = "$api_base/package/$slug.$remote_version.zip?license=$license_i_surely_paid_for";

    // If a newer version is available, add the update.
    if ( $remote_version
        && version_compare( SHWPUC_PLUGIN_VERSION, $remote_version, '<' )
    ) {
        $obj              = new stdClass();
        $obj->slug        = $slug;
        $obj->new_version = $remote_version;
        $obj->url         = $download_url;
        $obj->package     = $download_url;

        $transient->response[ SHWPUC_PLUGIN_SLUG ] = $obj;
    }

    return $transient;
}

// Define the alternative API for updating checking
add_filter( 'pre_set_site_transient_update_plugins', 'shwpuc_check_for_update' );
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * Return the latest version of a plugin on
 * the remote update server.
 *
 * @return string|null $remote_version
 */
function shwpuc_get_remote_version( $slug ) {
    $api_base = SHWPUC_API_BASE_URL;
    $license  = 'XXX-YYY-ZZZ';
    $url      = "$api_base/version/$slug?license=$license";
    $request  = wp_remote_get( $url );

    if ( ! is_wp_error( $request )
         || wp_remote_retrieve_response_code( $request ) === 200
    ) {
        return $request['body'];
    }

    return null;
}
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * Add our self-hosted description to the filter
 *
 * @param boolean  $false
 * @param array    $action
 * @param stdClass $arg
 *
 * @return bool|stdClass
 */
function shwpuc_check_info( $false, $action, $arg ) {
    // This will be "self-hosted-plugin-updates"
    $slug = shwpuc_get_plugin_slug();

    // Abort early if this isn't our plugin.
    if ( $arg->slug !== $slug ) {
        return false;
    }

    // Set the server base URL. This should be replaced
    // with the actual URL of your update server.
    $api_base = SHWPUC_API_BASE_URL;
    $license  = 'XXX-YYY-ZZZ';
    $url      = "$api_base/info/$slug?license=$license";
    $request  = wp_remote_get( $url );

    if ( ! is_wp_error( $request )
        || wp_remote_retrieve_response_code( $request ) === 200
    ) {
        return unserialize( $request['body'] );
    }

    return null;
}

// Define the alternative response for information checking
add_filter( 'plugins_api', 'shwpuc_check_info', 10, 3 );
Enter fullscreen mode Exit fullscreen mode

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, because 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:

/**
 * Registers the routes needed by the plugins.
 *
 * @return void
 */
function shwpus_register_routes() {
    register_rest_route(
        'shwpus/v1',
        '/version/(?P<plugin>[\w-]+)',
        array(
            array(
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => 'shwpus_handle_plugin_version_request',
                'permission_callback' => 'shwpus_handle_permission_callback',
                'args'                => array(
                    'plugin' => array(
                        'description' => 'The plugin slug, i.e. "my-plugin"',
                        'type'        => 'string',
                    ),
                ),
            ),
        )
    );

    register_rest_route(
        'shwpus/v1',
        '/info/(?P<plugin>[\w-]+)',
        array(
            array(
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => 'shwpus_handle_plugin_info_request',
                'permission_callback' => 'shwpus_handle_permission_callback',
                'args'                => array(
                    'plugin' => array(
                        'description' => 'The plugin slug, i.e. "my-plugin"',
                        'type'        => 'string',
                    ),
                ),
            ),
        )
    );

    register_rest_route(
        'shwpus/v1',
        '/package/(?P<plugin>[\w.-]+)',
        array(
            array(
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => 'shwpus_handle_plugin_package_request',
                'permission_callback' => 'shwpus_handle_permission_callback',
                'args'                => array(
                    'plugin' => array(
                        'description' => 'The plugin slug with the version, ending in .zip, i.e. "my-plugin.2.0.0.zip"',
                        'type'        => 'string',
                    ),
                ),
            ),
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

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:

/**
 * @param WP_REST_Request $request
 *
 * @return true|WP_Error
 */
function shwpus_handle_permission_callback( $request ) {
    $slug = $request->get_param( 'plugin' );
    $license = $request->get_param( 'license' );

    if ( $license !== 'XXX-YYY-ZZZ' ) {
        return new WP_Error(
            401,
            'Invalid license',
            array(
                'slug' => $slug,
                'license' => $license
            )
        );
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

Check the version

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

/**
 * Finds the latest version for a given plugin.
 *
 * @param WP_REST_Request $request
 *
 * @return void
 */
function shwpus_handle_plugin_version_request( $request ) {
    // Retrieve the plugin slug from the
    // request. Use this slug to find the
    // latest version of your plugin.
    $slug = $request->get_param( 'plugin' );

    // This is hardcoded for demo purposes.
    // Normally you would fetch this from
    // your database or whatever other
    // source of truth you have.
    $version = '1.0.1';

    header('Content-Type: text/html; charset=utf-8');
    echo $version;
    die();
}
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * Fetches information about the latest version
 * of the plugin with the given slug.
 *
 * @param WP_REST_Request $request
 *
 * @return void
 */
function shwpus_handle_plugin_info_request( $request ) {
    $slug    = $request->get_param( 'plugin' );
    $version = '1.0.1';

    // This data should be fetched dynamically
    // but for demo purposes it is hardcoded.
    $info = new stdClass();
    $info->name = 'Self-Hosted WordPress Plugin Updates - Client';
    $info->slug = 'self-hosted-plugin-updates-client';
    $info->plugin_name = 'self-hosted-plugin-updates-client';
    $info->new_version = $version;
    $info->requires = '6.0';
    $info->tested = '6.5.3';
    $info->downloaded = 12540;
    $info->last_updated = '2024-05-23';
    $info->sections = array(
        'description' => '
            <h1>Self-Hosted WordPress Plugin Updates - Client</h1>
            <p>
                Demo plugin showcasing a client plugin
                which updates from a custom update
                server.
            </p>
        ',
        'changelog' => '
            <h1>We did exactly 3 things!</h1>
            <p>
                You thought this is going to be a huge update.
                But it\'s not. Sad face.
            </p>
            <ul>
                <li>Added a cool new feature</li>
                <li>Added another cool new feature</li>
                <li>Fixed an old feature</li>
            </ul>
        ',
        // You can add more sections this way.
        'new_tab' => '
            <h1>Woah!</h1>
            <p>We are so cool, we know how to add a new tab.</p>
        ',
    );
    $info->url = 'https://drazen.bebic.dev';
    $info->download_link = get_rest_url( null, "/shwpus/v1/package/$slug.$version.zip" );

    header('Content-Type: text/html; charset=utf-8');
    http_response_code( 200 );
    echo serialize( $info );
    die();
}
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * @param WP_REST_Request $request
 *
 * @return void
 */
function shwpus_handle_plugin_package_request( $request ) {
    // Contains the plugin name, version, and .zip
    // extension. Example:
    // self-hosted-plugin-updates-server.1.0.1.zip
    $plugin = $request->get_param( 'plugin' );

    // The packages are located in wp-content for
    // demo purposes.
    $file = WP_CONTENT_DIR . "/packages/$plugin";

    if ( ! file_exists( $file ) ) {
        header( 'Content-Type: text/plain' );
        http_response_code( 404 );
        echo "The file $file does not exist.";
        die();
    }

    $file_size = filesize( $file );

    header( 'Content-Type: application/octet-stream' );
    header( "Content-Length: $file_size" );
    header( "Content-Disposition: attachment; filename=\"$plugin\"" );
    header( 'Access-Control-Allow-Origin: *' );
    http_response_code( 200 );
    readfile( $file );
    die();
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (3)

Collapse
 
usbpaul profile image
Paul Bakker • Edited

Thanks for your article.
Maybe you could add to it how the 'shwpus_register_routes' action could be added to the 'rest_api_init' hook.
I.e. with something like

add_action( 'rest_api_init', 'shwpus_register_routes');
Enter fullscreen mode Exit fullscreen mode
Collapse
 
drazenbebic profile image
Drazen Bebic

Very good point, thank you! I'll update the article ASAP.

btw, have you managed to get it working? Any difficulties?

Collapse
 
usbpaul profile image
Paul Bakker

Not finished with my implementation yet. Will let you know.