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

Shipping

Support, Deprecation and the Long Tail

How updates reach an installed plugin, how to retire a function without breaking its callers, and what uninstall.php should delete from a real store.

By the time this plugin is installed somewhere real, the code is the smallest part of the problem. The site owner gets an update notice they did not ask for, their courier history lives in two tables only this plugin understands, and the public tracking page prints PHP warnings when the theme changes. None of that is in the repository.

The update is the first thing to understand, because it delivers every other mistake. WordPress does not poll your repository; it asks the directory API once or twice a day, and that API decides what to tell the site from the headers and readme you shipped. If the readme's Tested up to falls behind, the site is told nothing, and "nothing" looks exactly like "there is no update".

The update check is a request the directory answers with your metadata

Once or twice a day WordPress sends the installed plugin's slug and version to api.wordpress.org and stores the answer in the update_plugins transient. If the directory has a higher version and the site's WordPress and PHP meet the plugin's declared floors, the plugin appears in the updates list. The site never contacts your server, which is why guideline 8 forbids shipping your own updater.

That answer also carries the fields the plugins screen displays, including tested, requires and requires_php. Core uses one of them directly, in the plugin information screen:

$tested_wp = ( empty( $api->tested ) || version_compare( get_bloginfo( 'version' ), $api->tested, '<=' ) );

if ( ! $tested_wp ) {
	$warning = __( '<strong>Warning:</strong> This plugin <strong>has not been tested</strong> with your current version of WordPress.' );
}

The same value has a second effect that is easier to miss. In wp-admin/includes/class-wp-plugins-list-table.php, auto-update is only offered for a plugin that appears in the update API's response or no_update list, because that is what sets update-supported to true, and the checkbox is rendered with $enabled = in_array( $plugin_file, $auto_updates, true ) && $plugin_data['update-supported']. A plugin the API stops returning has no auto-update toggle at all. So Tested up to is not a badge. It is the consent form that lets WordPress keep the plugin current while nobody is watching.

Three values in this plugin need keeping straight: Tested up to: 7.0 in the readme, Requires at least: 6.2 in both files, and WC tested up to: 9.0, a WooCommerce convention the directory passes through without validating. Two are stale: the course targets WooCommerce 11.2, and the bootstrap declares HPOS compatibility through FeaturesUtil::declare_compatibility( 'custom_order_tables', SFCT_FILE, true ). A stale compatibility claim next to a real compatibility declaration tells a store owner the plugin is untested against software the code was written for.

Deprecation is a promise with a version number attached

Renaming sfct_tracking_url() to sfct_get_tracking_url() is a one-line change for you and a fatal error for any site, snippet or companion plugin that calls the old name. The way out is to keep the old name as a wrapper. WordPress has a function for exactly this, and it takes a version argument that tells the next developer when the clock started:

function sfct_tracking_url( $tracking_id ) {
	_deprecated_function( __FUNCTION__, '1.2.0', 'sfct_get_tracking_url()' );

	return sfct_get_tracking_url( $tracking_id );
}

I ran that wrapper against WordPress 7.0.7's own _deprecated_function() with the legacy name and its replacement, and again with only two arguments. With debug mode off, nothing is printed. With it on:

Function sfct_tracking_url is <strong>deprecated</strong> since version 1.2.0! Use sfct_get_tracking_url() instead.
Function sfct_old_helper is <strong>deprecated</strong> since version 1.2.0 with no alternative available.

The <strong> tags are in the output because _deprecated_function() passes the string through wp_trigger_error(), which expects HTML for the admin notice it may become. Two details explain why this is a diagnostic rather than a log: the message is only built when WP_DEBUG is true, and it fires the deprecated_function_run action either way. That action is how you count calls on a live site without turning on debug mode everywhere.

The version argument is not documentation. It travels into the message, the deprecated_function_run payload and the notice text, and its job is to tell a site owner how long the wrapper has existed. Never deprecate against a future release you have not shipped. Pair the wrapper with a removal plan you intend to keep, and say the plan in the changelog, because a wrapper with no removal date is a permanent second API.

A breaking change at 1.2.0 costs more than the same change at 2.0.0

Nothing in WordPress enforces semantic versioning, which makes it a promise you make to strangers rather than a rule you follow. The promise matters because WordPress does the opposite of what a package manager does: it updates plugins in place, on live stores, without a lock file and often without a human reading the changelog. The version number is the only part of that process a store owner can act on.

Anything a site owner has to do is major. Removing or renaming a public function, changing a hook's arguments, renaming an option or a postmeta key, changing what a shortcode attribute means, or altering a table in a way that cannot be reversed: all of that is 2.0.0. Adding an optional filter, a status, a column with a default, or a capability check is minor. Renaming an internal helper nobody outside the plugin calls is minor with a deprecation wrapper, and if you are wrong about "nobody", the wrapper is what saves you.

A schema change is the loudest kind of breaking change

Adding a column is minor because dbDelta() can add it. Dropping or renaming one is major, because dbDelta() never drops anything, so you write the ALTER yourself and you own the rollback story. There is no rollback story in this plugin, since it stores no schema version at all.

The two numbers buy different things. A 1.2.1 release saying "this fixes a PHP warning" gets installed by people who update without reading. A 2.0.0 release gets read, and it gets a support thread, and it should. If you cannot write one clear sentence explaining what a site owner must do, the release is not major, it is broken.

Uninstall is a decision about someone else's data

Deactivation and deletion are different operations, and the plugin sits in the middle of them. Deactivation runs sfct_plugin_deactivate(), which flushes rewrite rules, and it keeps every table, option and order meta row. Deletion is what removes the files, and it is the only moment uninstall.php runs. The Plugin Handbook's table for that distinction is short: flushing permalinks and cache belongs in deactivation, and removing options and tables belongs in uninstall.

This plugin has no uninstall.php, so deleting it leaves two tables, four options and one transient behind, plus fourteen order meta keys across every order that ever carried tracking. What a correct uninstall would touch:

DataScopeDelete on uninstall
wp_sfct_delivery_logs, wp_sfct_delivery_companiesplugin-owned tablesyes
sfct_settings, sfct_logistics_statuses, sfct_template_settings, sfct_tracking_page_idplugin-owned optionsyes
sfct_woocommerce_dependency_redirectten-minute transientyes
_sfct_tracking_id and thirteen other order meta keysthe store's order recordsdeliberately
WooCommerce orders, customers, productsnot the plugin's datanever

Idempotency is the property that makes the file safe to run twice, because an uninstall can be interrupted by a time limit or a failed statement. A second run must be a no-op rather than a fatal error, which DROP TABLE IF EXISTS gives you for tables; delete_option() and delete_transient() succeed whether or not a row existed. The file also cannot depend on the plugin's code being loaded, because it runs without the plugin's bootstrap.

The two custom tables are the plugin's own records, so deleting them is the easy call. The order meta is the decision. Delete it and a WooCommerce order loses its tracking ID, assigned courier, logistics status and estimated date: the order survives, but the columns staff read every day are blank, and there is no undo because the tables holding the other half are gone. Keep it and the order keeps a dangling _sfct_company_id pointing at a table that no longer exists. Both choices are defensible, which is why the bad version of this file is the one that decides silently.

My preference is to delete the tables and options, keep the order meta by default, and make deleting the meta an explicit opt-in that defaults to off. The cost is that an owner who wants a clean database has to find a setting before deleting the plugin. A confirmation screen during uninstall is not an option: WordPress gives uninstall.php no UI.

Support is the only telemetry a free plugin gets

The review team handles compliance. Nobody handles "the timeline renders twice on the order page when the theme is a block theme" except the person reading the forum. Support threads are the only field data a directory plugin receives, and they arrive as bug reports from people who are already annoyed. Treating them as an engineering input rather than an inbox is the job: a report you can reproduce is a test case, three reports that share a cause are a design bug, and a report you cannot reproduce is evidence about a configuration you never tested. This plugin's own history shows it from the other side: 1.1.140 through 1.1.178 is a long run of small releases whose changelog entries are almost all corrections to copy, layout and localisation found after release.

The honest options for a plugin you stop maintaining are four, and only three are responsible. Maintain it, even slowly, and say in the readme when the last release was. Hand it over through the directory's take-over process, which exists so a plugin with users can get a new maintainer instead of dying. Close it deliberately: a closed plugin still shows a message telling users what happened, which is more than an abandoned one does. What is not responsible is the fourth, leaving it untouched while security reports arrive, because the site that gets compromised is not your test site.

Lab 50 minutes

Write the two files this plugin is missing, against a real database.

First, uninstall.php. Delete the two tables, the four options and the transient. Leave the order meta alone. Then make it run twice in a row without an error, and show the output of both runs. Say which statements would fail on a second run if you removed the guards you chose.

Second, a deprecation wrapper for one function in the plugin that you would rename. Show the output with debug mode on and off, and add the deprecated_function_run action to a small mu-plugin that counts calls into an option, so a site owner could see whether anything still calls the old name.

Then answer, in writing: which of the fourteen _sfct_ order meta keys would you delete on uninstall, and what does a store lose for each one you keep?

Show the solution and the reasoning
<?php
/**
 * Runs when the plugin is deleted, not when it is deactivated.
 */

if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	exit;
}

global $wpdb;

$sfct_tables = array(
	$wpdb->prefix . 'sfct_delivery_logs',
	$wpdb->prefix . 'sfct_delivery_companies',
);

foreach ( $sfct_tables as $sfct_table ) {
	$wpdb->query( "DROP TABLE IF EXISTS {$sfct_table}" );
}

$sfct_options = array(
	'sfct_settings',
	'sfct_logistics_statuses',
	'sfct_template_settings',
	'sfct_tracking_page_id',
);

foreach ( $sfct_options as $sfct_option ) {
	delete_option( $sfct_option );
}

delete_transient( 'sfct_woocommerce_dependency_redirect' );

// Options are per site, so clean them out of every site on the network.
if ( is_multisite() ) {
	$sfct_sites = get_sites( array( 'fields' => 'ids', 'number' => 1000 ) );

	foreach ( $sfct_sites as $sfct_site_id ) {
		switch_to_blog( (int) $sfct_site_id );

		foreach ( $sfct_options as $sfct_option ) {
			delete_option( $sfct_option );
		}

		delete_transient( 'sfct_woocommerce_dependency_redirect' );
		restore_current_blog();
	}
}

The guard is the first line that matters. The Handbook is specific that WP_UNINSTALL_PLUGIN is defined during an uninstall.php invocation and is not defined when uninstall runs through register_uninstall_hook(), which is one reason to prefer the file. The second detail is that options are per site, so the loop runs again inside each blog. On a network where the plugin was activated site by site, that loop is the difference between cleaning up one site and leaving the other thirty-nine with stale options.

The order meta is untouched. The plugin's _sfct_tracking_id, _sfct_company_id, _sfct_logistics_status and _sfct_estimated_date are what a store's staff read during a delivery dispute. With the plugin's tables gone, those keys are inert strings in the postmeta table; they harm nothing and can be removed later by a script the owner runs deliberately.

The count of fourteen keys is the other reason not to delete them by default. A list that long, written into an uninstall file, is out of date by the next release, because nothing fails when a key is added to the plugin and not to the list. The uninstall file that deletes everything quietly misses the fifteenth key.

The deprecation count has the same shape. Nothing inside the plugin can tell you whether a snippet on somebody's site calls sfct_tracking_url(), so the wrapper stays until you can say the number is zero or you accept the risk in a major release.

Six modules in, the pattern that holds is that every decision in this plugin is a decision about somebody else's site: a version number two files disagree about, a schema with no version to compare, a readme describing a release nobody wrote down, an upgrade prompt judged against a numbered rule, and a delete button that either keeps a store's records or destroys them. The code is the easy part. Knowing which of those you are allowed to decide alone is the job.

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 Building the Sifero Courier Timeline Plugin. 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.