Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 093 / 100

Plugin update mechanisms: a hands-on guide for engineers

Ship updates to users.

Expona Admin9 min read

Plugin update mechanisms: a hands-on guide for engineers

Users expect their plugins to stay current without manual intervention, yet many teams underestimate the complexity of shipping updates reliably at scale. Whether you're maintaining a free directory plugin or a premium product with custom licensing, understanding plugin update mechanisms separates a mature operation from one that frustrates users with stale code. This guide walks through the mechanics we've learned deploying updates across thousands of installations.

Directory plugins update automatically

The WordPress plugin directory handles updates for free plugins through a standardized mechanism. When you push a new version to the directory, WordPress.org parses your plugin header, extracts the version number, and makes that information available via its API. Your installed plugin checks against this API periodically—by default every 12 hours—and prompts users to update when a newer version exists.

The magic happens in your plugin's main file header. WordPress reads these fields to determine update eligibility:

php
<?php
/**
 * Plugin Name: Analytics Dashboard
 * Description: Track visitor metrics and conversions
 * Version: 2.3.1
 * Author: Your Team
 * License: GPL-2.0-or-later
 * Text Domain: analytics-dashboard
 */

WordPress core uses the Version field as the source of truth. When you tag a release on the SVN repository at wp.org, the directory's build system automatically increments the version in the directory's readme.txt, triggering availability checks across all installations.

For directory plugins, you don't need to do anything special—the update infrastructure is free and automatic. However, you must follow semantic versioning consistently. Jump from 1.0.0 to 1.0.1 for patches, to 1.1.0 for minor features, and to 2.0.0 for breaking changes. Users and monitoring systems rely on this signal to decide whether an update is safe.

One critical detail: test your plugin on the staging environment against the current WordPress version before pushing to the directory. Users see updates appear within hours, and a broken release spreads quickly. We've seen a single bad commit disable hundreds of sites.

Custom update servers for premium plugins

Premium plugins need a custom update server because WordPress.org doesn't host paid code. You control the server, the update logic, and the eligibility checks—determining which users receive which versions based on their license.

The standard approach uses the plugins_api filter and pre_set_site_transient_update_plugins hook. Your plugin queries your custom endpoint for update metadata, then WordPress core handles the actual download and installation:

php
add_filter( 'pre_set_site_transient_update_plugins', function( $transient ) {
	if ( empty( $transient->checked ) ) {
		return $transient;
	}

	$plugin_file = 'premium-crm/premium-crm.php';
	$response = wp_remote_post( 'https://api.yoursite.com/update-check', array(
		'body' => array(
			'plugin'  => $plugin_file,
			'version' => get_plugins()[ $plugin_file ]['Version'],
			'site'    => home_url(),
			'license' => get_option( 'premium_crm_license_key' ),
		),
		'timeout' => 5,
	) );

	if ( is_wp_error( $response ) ) {
		return $transient;
	}

	$body = json_decode( wp_remote_retrieve_body( $response ) );

	if ( ! empty( $body ) && version_compare( $body->new_version, $plugin_version, '>' ) ) {
		$transient->response[ $plugin_file ] = (object) array(
			'id'            => 'premium-crm',
			'slug'          => 'premium-crm',
			'plugin'        => $plugin_file,
			'new_version'   => $body->new_version,
			'url'           => $body->homepage,
			'package'       => $body->download_url,
			'tested'        => $body->tested,
			'requires'      => $body->requires_wp,
			'requires_php'  => $body->requires_php,
			'changelog'     => $body->changelog,
		);
	}

	return $transient;
} );

Your server-side endpoint receives the license key and validates it against your database. Only return update metadata if the license is active and the user's plan includes the latest version. This prevents license sharing and ensures revenue protection.

The download URL must point to a .zip file containing your plugin code. Many teams host this on AWS S3 or a CDN to distribute the load and avoid server strain when hundreds of sites update simultaneously. Generate signed URLs that expire after a short window—say, 1 hour—to prevent direct distribution of your code.

Store license validation responses in a transient for up to 24 hours. This reduces server load and provides graceful degradation if your API is temporarily unavailable. Users can still update if their license was valid when last checked.

Version checks and changelogs

Version comparison logic must be bulletproof because a single mistake can push the wrong code to thousands of installations. PHP's version_compare() function handles most cases, but edge cases trip up teams regularly.

Always use semantic versioning: MAJOR.MINOR.PATCH. Avoid versions like 1.0 or 2023.01.15—they confuse the comparison function and create update loops. A version like 1.0.0-beta will be treated as less than 1.0.0, which can cause unexpected behavior if you're not careful.

php
// Correct comparisons
version_compare( '2.0.0', '1.9.9', '>' ); // true
version_compare( '1.0.0', '1.0.0-beta', '>' ); // true
version_compare( '1.10.0', '1.9.0', '>' ); // true (numeric, not string)

// Be explicit about what you're checking
if ( version_compare( $local_version, $remote_version, '<' ) ) {
	// Update is available
}

Changelogs belong in your plugin's readme.txt for directory plugins, or served from your API for premium plugins. Users need to know what changed before clicking update, especially for production sites. Document fixes, new features, breaking changes, and deprecations clearly:

php
== Changelog ==

= 2.3.1 =
* Fixed: Email notifications not sending for bulk actions
* Fixed: XSS vulnerability in report export (thanks @security-researcher)
* Improved: Reduced database queries on dashboard by 40%

= 2.3.0 =
* Added: Custom field templates for lead capture forms
* Deprecated: Legacy webhook format (removed in 3.0)
* Fixed: Timezone handling in scheduled reports
* BREAKING: Dropped support for WordPress 5.6 and below

Include the changelog in your update API response so WordPress displays it in the update modal. Users appreciate transparency, and clear release notes reduce support tickets.

Database upgrade routines

Plugin code updates alone aren't enough. Your plugin may need to migrate data, add new options, or restructure tables. Handle this with a versioning system that tracks which migrations have run.

Store the current schema version as a WordPress option:

php
// In plugin activation or initial setup
update_option( 'analytics_dashboard_db_version', '2.3.0' );

// On every plugin load, check if upgrades are needed
add_action( 'plugins_loaded', function() {
	$current_version = get_option( 'analytics_dashboard_db_version' );
	$new_version = '2.3.1';

	if ( ! $current_version || version_compare( $current_version, $new_version, '<' ) ) {
		analytics_dashboard_upgrade_database( $current_version, $new_version );
	}
} );

Write discrete upgrade functions keyed to specific versions. This prevents running migrations multiple times if a user deactivates and reactivates your plugin:

php
function analytics_dashboard_upgrade_database( $from_version, $to_version ) {
	global $wpdb;

	// Upgrade to 2.1.0: Add indexed column for faster queries
	if ( version_compare( $from_version, '2.1.0', '<' ) ) {
		$wpdb->query( "
			ALTER TABLE {$wpdb->prefix}analytics_events 
			ADD COLUMN indexed_at DATETIME AFTER created_at,
			ADD INDEX idx_indexed_at (indexed_at)
		" );
		update_option( 'analytics_dashboard_db_version', '2.1.0' );
	}

	// Upgrade to 2.2.0: Backfill indexed_at for existing records
	if ( version_compare( $from_version, '2.2.0', '<' ) ) {
		$wpdb->query( "
			UPDATE {$wpdb->prefix}analytics_events 
			SET indexed_at = created_at 
			WHERE indexed_at IS NULL
		" );
		update_option( 'analytics_dashboard_db_version', '2.2.0' );
	}

	// Upgrade to 2.3.1: Archive old data
	if ( version_compare( $from_version, '2.3.1', '<' ) ) {
		$archive_before = gmdate( 'Y-m-d H:i:s', strtotime( '-90 days' ) );
		$wpdb->query( $wpdb->prepare(
			"INSERT INTO {$wpdb->prefix}analytics_archive 
			 SELECT * FROM {$wpdb->prefix}analytics_events 
			 WHERE created_at < %s",
			$archive_before
		) );
		$wpdb->query( $wpdb->prepare(
			"DELETE FROM {$wpdb->prefix}analytics_events 
			 WHERE created_at < %s",
			$archive_before
		) );
		update_option( 'analytics_dashboard_db_version', '2.3.1' );
	}
}

Always test database upgrades on a copy of production data. A migration that works on a fresh install may fail or corrupt data on an older schema. We've learned this the hard way with broken character encodings and constraint violations.

For large tables, avoid synchronous operations that lock the database. Instead, queue background jobs using WordPress's Action Scheduler or WP-Cron to process data in batches. Display a notice to the user that the upgrade is in progress, and check on subsequent page loads until completion.

Common mistakes

  • Forgetting to increment the version number: Users won't receive the update if your plugin file version doesn't increase. This seems obvious but happens in rushed releases.
  • Testing updates only on localhost: Local WordPress instances behave differently from production. Test on a staging server that mirrors your production environment, including plugins and themes.
  • Assuming all users upgrade immediately: Some sites sit dormant for months. When they finally update, your plugin jumps multiple versions. Ensure upgrade functions handle skipped versions gracefully.
  • Breaking backward compatibility without warning: Remove deprecated functions in a major version only, and give users at least one minor release to adapt. Document removals clearly in changelogs.
  • Running long database migrations on plugin load: If a migration takes more than a few seconds, move it to a background job. Synchronous operations lock up the site and frustrate users.
  • Not validating license keys on premium updates: Without validation, anyone with the download link can use your plugin. Always check the license during the update check and before serving the download.

Key takeaways

  • Directory plugins leverage WordPress.org's automatic update infrastructure—ensure your version header follows semantic versioning and test before deploying.
  • Premium plugins require a custom update server that validates licenses before returning update metadata and download links.
  • Version comparison logic must account for pre-release versions and numeric sorting; use version_compare() explicitly and test edge cases.
  • Changelogs should document fixes, new features, deprecations, and breaking changes—include them in both readme.txt and API responses.
  • Database migrations need discrete version-keyed functions that handle skipped versions and large datasets via background jobs, not synchronous operations.
  • Test all updates on staging environments that mirror production before releasing to users, and monitor early updates for issues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles