Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 18 / 18 35 MIN

Shipping

Updates, Support and Deprecation

How the directory serves updates, what Tested up to really controls, deprecating a function without breaking callers, and the honest end-of-life options.

You ship 2.0.0 on a Tuesday. By Wednesday the forum has three threads, and none are about the feature you announced. One site's theme called wpsifero_notes_count(), which you renamed. One staging copy is still on 1.9 because updates were disabled in wp-config.php and nobody remembers doing it. One user is asking why the plugin page says the plugin has not been tested with the current WordPress when they installed it yesterday.

Shipping is not the end of the engineering work; it starts a relationship with an installed base you cannot see. Four mechanisms run it: the update check, deprecation, uninstall, and the support channel.

The directory serves updates through a checked-in transient

WordPress does not poll the directory on every admin page. wp_update_plugins() runs on the plugins and update screens, and its first act is to bail if the site checked in less than twelve hours ago. The result is cached in the update_plugins transient, so the "update available" badge is a local read, not a network request.

When the check does run, WordPress posts the site's plugin list and WordPress version to the directory's API at api.wordpress.org and gets back three arrays: response for plugins with a newer version, no_update for the rest, and translations. Each entry in response carries the new version, the zip URL, icons and banners, its tested value, and optionally requires_php and an autoupdate flag. The site compares that version against the installed file's Version: header, which is what makes an unchanged header mean "no update".

That is why a release can take hours to appear: the directory regenerates the zips and API data from the SVN commit, and the site in front of you may still hold a twelve-hour-old transient. Guideline 15 covers the other half: version numbers must be incremented for each release, because that is the only signal the check has that anything changed. Commit a fix, leave the header at 2.0.0, and no site is ever told.

Updates are a site decision, and the version number is the only signal

The decision to auto-update is stored per site in the auto_update_plugins site option. The upgrader asks WP_Automatic_Updater::should_update() for each offer, and that method runs the offer's own autoupdate value and the site's stored choice through the auto_update_plugin filter. A plugin can influence auto-updates in two ways, the directory's autoupdate response value and that filter, and neither is a veto over a site owner who has enabled them.

Force-disabling auto-updates has consequences. Automatic updates are how a security fix reaches the sites that never log in, so a plugin that opts out is one whose patches arrive only for the attentive. Make the update safe instead: ship the breaking change in a major version, keep the deprecated path for a release, and let the update happen.

Tested up to is a warning, not an update gate

The directory reads Tested up to from the readme and returns it as the tested value in the API response. WordPress compares the site's version against it in one place: the plugin installer's compatibility warning. It does not stop the update check, an automatic update, or the activation of an installed plugin. A site on WordPress 7.1 auto-updates a plugin whose readme says Tested up to: 7.0, and gets a notice on the install screen.

Do not confuse the two notices

The directory's "Out of Date" notice fires when a plugin does not support the last three major WordPress releases, and it is cleared by editing the readme and pushing, with no code change. The admin warning fires when the site's version is higher than the tested value. Neither blocks an update, and a value bumped without testing is a claim you cannot back.

Deprecation is a message with a version and a replacement

The rename in the opening story did not have to break anything. WordPress ships a helper for this, and its signature is the contract: the deprecated name, the version that deprecated it, and the replacement.

/**
 * Count notes, the way the 1.x line did.
 *
 * @deprecated 2.0.0 Use wpsifero_notes_count_notes() instead.
 * @return int
 */
function wpsifero_notes_count(): int {
	_deprecated_function( __FUNCTION__, '2.0.0', 'wpsifero_notes_count_notes()' );

	return wpsifero_notes_count_notes();
}

With WP_DEBUG enabled, calling that function prints:

Deprecated: Function wpsifero_notes_count is deprecated since version 2.0.0! Use wpsifero_notes_count_notes() instead. in /wp-includes/functions.php on line 6170

The helper is thinner than it looks. It fires the deprecated_function_run action, and then, only when WP_DEBUG is true and the deprecated_function_trigger_error filter is still on, it builds that message and passes it to wp_trigger_error(). There is no state, no counter and no admin notice.

Three consequences follow. The deprecated function must keep working, because the notice is the entire mechanism; a function that warns and then returns the wrong thing is a worse bug than a silent rename. The version argument is the deprecating release, not the release you plan to remove it in. And the owner never sees any of this without WP_DEBUG, which makes deprecation a courtesy to developers, not a user migration tool.

The pattern has siblings for other removals: _deprecated_argument() for a parameter and _deprecated_hook() for a hook. Use the one that names what you removed; the message text is how a developer finds you.

A breaking change is what the major version is for

Semantic versioning gives the installed base one signal they can act on, and it is the major segment. Removing a function, changing a filter callback's argument count, changing a stored row's shape without migrating it, or raising the PHP floor above what a site runs are all breaking, and all belong in a major release.

The release that removes an old name should not be the release that deprecates it. Deprecate in the major version, keep the shim for at least one major cycle, then remove it in a later major with the removal stated in the changelog and the upgrade notice. Sites that never read anything get a full cycle of working code, and the developers who called your function get a year of WP_DEBUG output telling them what to change.

The Upgrade Notice readme section is capped at 300 characters per version and is shown on the update screen before the user clicks. That is the place to say "this changes X, read the changelog if you have customised Y".

Uninstall is not deactivation, and both must be idempotent

Deactivation happens when a site owner clicks Deactivate or an update replaces the files. It is a pause, and it must leave everything in place: options, custom tables, scheduled events. WordPress does not run the deactivation hook on update, which is deliberate, because if it did, every update would tear down plugin state mid-request. Use register_deactivation_hook() only for what must stop, such as a scheduled event that would otherwise fire against missing code.

Uninstall happens when the site owner deletes the plugin, and it is the only point at which destruction is correct. The two ways to hook it are mutually exclusive. register_uninstall_hook() stores a callback in the non-autoloaded uninstall_plugins option, so WordPress must include your main plugin file to run it, and code outside a function there executes during deletion. uninstall.php avoids that include, and WordPress ignores the registered callback when the file exists.

<?php
/**
 * Removes everything the plugin stored. Runs only through the uninstall link.
 */

defined( 'WP_UNINSTALL_PLUGIN' ) || exit;

// Attachments are rows and files, so they are not covered by delete_option().
$notes = get_option( 'wpsifero_notes', array() );

if ( is_array( $notes ) ) {
	foreach ( $notes as $note ) {
		if ( isset( $note['attachment_id'] ) ) {
			wp_delete_attachment( (int) $note['attachment_id'], true );
		}
	}
}

delete_option( 'wpsifero_notes' );
delete_option( 'wpsifero_notes_version' );

Two details there are load-bearing. The WP_UNINSTALL_PLUGIN guard stops the file doing anything when someone requests it directly; WordPress defines that constant immediately before including it. And the file must be safe to run twice, because it will be: a host migrating a site, or a site owner who deletes the plugin and uploads it again. Idempotence means delete_option() rather than update_option() arithmetic, no DROP TABLE without IF EXISTS, and no assumption that a row exists.

Support is an engineering input, not a public relations cost

The forum that comes with a directory plugin is a record of where the plugin's model diverges from what users expect. Read as data, the recurring threads are design bugs sorted by how many people hit them: the same question three times in a month is missing documentation, and a recurring "it stopped working after I did X" names a missing guard. The awkward part is scope, because a plugin that does everything its users ask becomes one nobody can maintain. The cheapest no is a documented limit.

Stopping is a choice with four honest shapes

Every plugin ends, and the options differ in what they owe the installed base.

OptionWhat it meansWhat it owes users
Hand it over Transfer the plugin to a new owner, or add committers. A readme note, and due diligence on who gets it.
Maintenance only Security and compatibility fixes only, no new features. An honest readme line, and continuing to bump Tested up to.
Mark it deprecated The readme says deprecated and names the replacement. The directory may close a plugin whose readme says this. A migration path that works, rather than an announcement.
Close it The page shows as closed and the zips stop. SVN stays readable so others can fork. The reason, as plainly as you can give it. Closing is permanent.

The option that is not on the list is silence. An abandoned plugin that still shows a compatibility badge keeps installing itself on new sites, and its page is the only place a prospective user can find out. The directory helps, since a closed plugin shows a reason after sixty days and one untouched for six months may be closed without notice, but the author who writes the note first chooses the replacement.

One observation runs through all six modules and lands here. The plugin model puts a hook at almost every point in a request, which makes it tempting to treat each feature as a local problem with a local solution. Releases, readmes, migrations and deprecations are that argument at a longer timescale: a plugin is a promise made to a site you will never see, on a WordPress version you have not tested, over rows a previous version of your own code wrote. The discipline that looked like discipline, from the migration written before the feature to capabilities in and escaping out and an honest version number, is one habit: assuming the next person to run your code is not you.

Lab 45 minutes

Take the wpsifero_notes_count() rename from this lesson and ship it properly.

Write the deprecated shim, the uninstall.php, and the readme sections for a 2.0.0 release: the Changelog entry, the Upgrade Notice within 300 characters, and one FAQ entry for the customers who see the warning.

Then answer three questions. What release removes the shim, and what has to be true first? A site has WP_DEBUG off and auto-updates enabled, so what tells its owner the function they call is deprecated? And for a plugin with 4,000 installs, no replacement and an author who has stopped, which end-of-life shape would you pick?

Show the solution and the reasoning

The shim is the code block above, with the docblock's @deprecated line kept in step with the runtime call. The readme carries what a user can act on: the changelog entry for the record, the upgrade notice for the update screen, and the FAQ entry for the thread that will arrive anyway.

== Changelog ==

= 2.0.0 =
* Renamed `wpsifero_notes_count()` to `wpsifero_notes_count_notes()`.
* Added a `created` timestamp to existing notes, migrated on update.

== Upgrade Notice ==

= 2.0.0 =
Notes gain a created timestamp during the update, and
`wpsifero_notes_count()` is deprecated in favour of
`wpsifero_notes_count_notes()`. The old name still works in 2.x.

== Frequently Asked Questions ==

= Why do I see "Function wpsifero_notes_count is deprecated"? =

Something on your site calls the old name. It still works in 2.x, so nothing
is broken yet. Update the caller to `wpsifero_notes_count_notes()`; the old
name goes in a future major release.

The shim goes in a later major, 3.0.0 at the earliest, after the removal was announced in a 2.x upgrade notice. The condition is not a date but a full major cycle during which a caller running WP_DEBUG would have seen the message. Removing it in a patch makes semantic versioning worthless.

A site running with WP_DEBUG off gets nothing at runtime, so what reaches its owner is the upgrade notice and the changelog. That is the argument for putting the rename in both and keeping the shim working: for most installs the readme is the only deprecation channel. Logging it to the debug log is defensible; a notice on every admin page for a function that still works is the alerting guideline 11 asks you to avoid.

For 4,000 installs and no replacement, maintenance only, unless you find a credible new owner. Closing the plugin stops the zips, stranding those sites on whatever version they have, including the ones that never received the last security fix. Maintenance keeps the important half: bump Tested up to, fix security and compatibility problems, tag a release. The readme should say so, because a prospective user deserves to know what they are depending on.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of WordPress Plugin Development. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.