Custom WordPress Plugin Update Server

on in WordPress
Last modified on

This is an automatic updater for plugins hosted outside WordPress.org.

Currently, WordPress doesn’t have an easy way for plugins which are not hosted on WordPress.org to use its built-in automatic update feature. This solution also enables developers to sell their plugins and still retain the convenience of automatic updates for their users.

Table of Contents

Recently, the Git Updater plugin received a paid upgrade. Basically, private GitHub repositories are not updatable any more. So, one of my plugins and one of my themes, both installed on 200+ websites, stopped being updated. As a developer, I wanted to have full control over the update process, so I coded my own functionality.

There are several good tutorials out there, but most of them are outdated, or do not apply to multiple custom (non-WP.org) plugins on the same WordPress installation.

Custom Plugin Updater

This requires one file in the plugin, which I call updater.php. I then call this function from the main plugin file. One requirement is that the plugin slug is the same as the plugin folder, for example:

wp-my-awesome-plugin/wp-my-awesome-plugin.php

This is a WordPress standard requirement anyway, so it’s good practice to have it this way.

updater.php 🠗

<?php
// Take over the update check
add_filter( 'pre_set_site_transient_update_plugins', 'gb_check_for_plugin_update' );

function gb_check_for_plugin_update( $checked_data ) {
    $api_url     = 'https://www.example.com/api/wp/update/';
    $plugin_slug = 'plugin-slug';

    if ( empty( $checked_data->checked ) ) {
        return $checked_data;
    }

    $request_args = [
        'slug'    => $plugin_slug,
        'version' => $checked_data->checked[ $plugin_slug . '/' . $plugin_slug . '.php' ],
    ];

    $request_string = gb_prepare_request( 'basic_check', $request_args );

    // Start checking for an update
    $raw_response = wp_remote_post( $api_url, $request_string );

    if ( ! is_wp_error( $raw_response ) && ( (int) $raw_response['response']['code'] === 200 ) ) {
        $response = unserialize( $raw_response['body'] );
    }

    if ( is_object( $response ) && ! empty( $response ) ) { // Feed the update data into WP updater
        $checked_data->response[ $plugin_slug . '/' . $plugin_slug . '.php' ] = $response;
    }

    return $checked_data;
}

// Take over the Plugin info screen
add_filter( 'plugins_api', 'gb_plugin_api_call', 10, 3 );

function gb_plugin_api_call( $def, $action, $args ) {
    $api_url     = 'https://www.example.com/api/wp/update/';
    $plugin_slug = 'plugin-slug';

    // Do nothing if this is not about getting plugin information
    if ( $action !== 'plugin_information' ) {
        return false;
    }

    if ( (string) $args->slug !== (string) $plugin_slug ) {
        // Conserve the value of previous filter of plugins list in alternate API
        return $def;
    }

    // Get the current version
    $plugin_info     = get_site_transient( 'update_plugins' );
    $current_version = $plugin_info->checked[ $plugin_slug . '/' . $plugin_slug . '.php' ];
    $args->version   = $current_version;

    $request_string = gb_prepare_request( $action, $args );

    $request = wp_remote_post( $api_url, $request_string );

    if ( is_wp_error( $request ) ) {
        $res = new WP_Error( 'plugins_api_failed', __( 'An Unexpected HTTP Error occurred during the API request.</p> <p><a href="?" onclick="document.location.reload(); return false;">Try again</a>' ), $request->get_error_message() );
    } else {
        $res = unserialize( $request['body'] );

        if ( $res === false ) {
            $res = new WP_Error( 'plugins_api_failed', __( 'An unknown error occurred' ), $request['body'] );
        }
    }

    return $res;
}

function gb_prepare_request( $action, $args ) {
    global $wp_version;

    return [
        'body'       => [
            'action'  => $action,
            'request' => serialize( $args ),
            'api-key' => md5( get_bloginfo( 'url' ) ),
        ],
        'user-agent' => 'WordPress/' . $wp_version . '; ' . get_bloginfo( 'url' ),
    ];
}

Note how I hardcoded the API server URL and the plugin slug twice in the code above.

Custom Server Updater

The next step is the server API. This requires one PHP file and the plugin’s ZIP archive(s). The structure of the server is, as declared in the code above, 'https://www.example.com/api/wp/update/'.

So, in my /api/wp/update/ folder I have an index.php file with the code below.

/api/wp/update/index.php 🠗

<?php
/**
 * WordPress Update Server
 *
 * @version 0.0.1
 * @author  Ciprian Popescu (getbutterfly@gmail.com)
 */

$packages['plugin-slug'] = [
    'versions' => [
        '1.0.0' => [
            'name'              => 'WP Awesome Plugin',
            'version'           => '1.0.0',
            'new_version'       => '1.0.0',
            'date'              => '2023-02-10 14:17:00',
            'tested'            => '6.1.1',
            'package'           => 'https://www.example.com/api/wp/update/plugins/plugin-slug/plugin-slug-1.0.0.zip',
            //
            'author'            => '<a href="https://getbutterfly.com/">Ciprian Popescu</a>',
            'author_profile'    => 'https://profiles.wordpress.org/butterflymedia/',
            'homepage'          => 'https://getbutterfly.com/wordpress-plugins/plugin-slug/',
            'requires'          => '5.8',
            'requires_php'      => '7.0',
            'description'       => 'This is an awesome plugin!',
            'short_description' => 'This is an awesome plugin!',
        ],
    ],
    'info'     => [
        'url' => '',
    ],
];



// Process API requests
$action = (string) $_POST['action'];
$args   = unserialize( $_POST['request'] );

if ( is_array( $args ) ) {
    $args = array_to_object( $args );
}

$latest_package = array_shift( $packages[ $args->slug ]['versions'] );



// Process basic_check request
if ( $action === 'basic_check' ) {
    $update_info       = array_to_object( $latest_package );
    $update_info->slug = $args->slug;

    if ( version_compare( $args->version, $latest_package['version'], '<' ) ) {
        $update_info->new_version = $update_info->version;

        print serialize( $update_info );
    }
}



// Process plugin_information request
if ( $action === 'plugin_information' ) {
    $data = new stdClass;

    $data->slug              = $args->slug;
    $data->name              = $latest_package['name'];
    $data->version           = $latest_package['version'];
    $data->new_version       = $latest_package['new_version'];
    $data->last_updated      = $latest_package['date'];
    $data->download_link     = $latest_package['package'];
    $data->tested            = $latest_package['tested'];
    //
    $data->author            = $latest_package['author'];
    $data->author_profile    = $latest_package['author_profile'];
    $data->homepage          = $latest_package['homepage'];
    $data->requires          = $latest_package['requires'];
    $data->requires_php      = $latest_package['requires_php'];
    //
    $data->description       = $latest_package['description'];
    $data->short_description = $latest_package['short_description'];

    $data->sections = [
        'description' => $latest_package['description'],
    ];

    print serialize( $data );
}



function array_to_object( $array = [] ) {
    if ( empty( $array ) || ! is_array( $array ) ) {
        return false;
    }

    $data = new stdClass;

    foreach ( $array as $akey => $aval ) {
        $data->{$akey} = $aval;
    }

    return $data;
}

Note the path above –

https://www.example.com/api/wp/update/plugins/plugin-slug/plugin-slug-1.0.0.zip

– is where the plugin ZIP archive goes. The structure of the ZIP archive should be:

plugin-slug-1.0.0.zip
    /plugin-slug/
        /some-folder
        /plugin-slug.php
        /readme.txt
        /license.txt

That is all!

I am still working on improving both the updater and the server. The version is 0.0.1, but I have already deployed it successfully on the said 200+ websites.

I still have some issues where I can’t enable automatic updates, but I am working on it. Also, I am working on tracking downloads and building a nice interface with all the stats.

Related posts