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.
| Option | What it means | What 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.
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.