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.

Technical SEO specialist, JavaScript developer and senior full-stack developer. Owner of getButterfly.com.
If you like this article, go ahead and follow me on Twitter or buy me a coffee to support my work!
Thanks for this Ciprian. I found the same thing that all the other updater sites were out of date.
Did you make any progress on the automatic updater?
Ciprian,
I am so close to getting this working, thanks :o)
Line 55 of updater.php
$current_version = $plugin_info->checked[ $plugin_slug . ‘/’ . $plugin_slug . ‘.php’ ];
when clicking “view details” it throws an error:
Notice: Undefined property: stdClass::$checked in /wp-content/plugins/speakout/updater.php on line 55
Notice: Trying to access array offset on value of type null in /wp-content/plugins/speakout/updater.php on line 55
So it isn’t checking the current version by the look of it. Any ideas on this?
Also, this page could use some additional information to make it a little easier to follow. I would be happy to contribute if you are interested.
I have checked my updater now, thinking I may have updated it since first writing this article, but I did not. It’s the same, and it is working perfectly for 5 plugins and 2 themes.
It took me some trial and error to debug all issues. What you could do is to print the $plugin_info object and see what you are getting. It seems it’s null, so something is preventing the request. You could also try the Query Monitor plugin, maybe it gives you some more insight into the error.
And lastly, what would you add to make the tutorial easier to follow? I am interested.