Data that outlives the request
Options, Transients and Upgrade Routines
The plugin's install function is safe to run twice, which is what makes it safe. What belongs in an option, what in a transient, and how to gate an upgrade.
Options are where a plugin's memory goes when a table is more machinery than the data
deserves. They are also the easiest place in WordPress to lose a customer's configuration:
one update_option() on a code path that runs during an upgrade, and the
tracking prefix an owner set last year is back to its factory value. The plugin's install
routine avoids that, but it avoids it in a way you have to read twice, and the same routine
hides a problem that only shows up on stores that update plugins the normal way.
Everything here was run against a working WordPress 7.0.7, and the numbers below are from that run rather than from memory.
Three ways to remember something, and only one of them persists
An option lives in wp_options as a serialised value and stays until
something deletes it. A transient also lives in wp_options, as a pair of rows
with an expiry timestamp, and disappears when something asks for it after that timestamp.
Neither is memory: both are database rows.
SELECT option_name, autoload, LENGTH(option_value) FROM wp_options
WHERE option_name LIKE 'sfct%' OR option_name LIKE '\_transient%sfct%';
On the lab install that query returns four rows for the plugin. Two are the settings and
status options. Two are the halves of a transient, the value row and its
_transient_timeout_ partner, and that is the shape to expect from every
transient you set.
What the install routine actually does
Here is the function, quoted in full:
function sfct_install_default_options() {
if ( ! get_option( 'sfct_settings' ) ) {
add_option(
'sfct_settings',
array(
'tracking_prefix' => 'SFCT',
'tracking_format' => 'random',
'random_length' => 12,
'api_token' => wp_generate_password( 32, false, false ),
)
);
}
sfct_ensure_default_logistics_statuses();
}
The guard is the whole point. add_option() itself does nothing when the
option already exists, so the if is defensive rather than load-bearing, and
the same pattern appears one function down:
sfct_ensure_default_logistics_statuses() reads
get_option( 'sfct_logistics_statuses', null ) and returns early if it is a
non-empty array.
Reversing the choice to update_option() would change the meaning of the
code from "create these on a fresh install" to "reset these every time this runs". The
measurable difference is the api_token. It is generated with
wp_generate_password( 32, false, false ), which returns a 32-character
alphanumeric string, and it is the key a courier's webhook uses to talk to the site. If a
later version of the plugin ever replaced this call with an unconditional
update_option(), every store would lose its webhook authentication on the next
release, and the failure would look like the courier's problem.
Run the install twice on a store whose owner has already saved a tracking prefix of
MINE and an owner-supplied API token, and both survive unchanged. That is the
behaviour to protect in a test.
The autoload question, answered by the source rather than the docs
Every option carries an autoload flag. WordPress reads the preloadable ones in a single query on every request and keeps them in memory, which is fast when the list is short and expensive when a plugin adds a large value to it.
On this WordPress 7.0.7 install, both plugin options are stored with
autoload = 'auto'. That is not the plugin choosing and it is not the
add_option() default you might have in mind: modern WordPress writes
auto and decides at preload time, so an option smaller than the preload
threshold gets loaded on every request. The statuses option is 1,392 bytes, the settings
option 158 bytes, and neither is large enough to justify turning autoload off. Two options
of this size are not the problem. The pattern that is a problem is a plugin that writes a
serialised array of its entire settings screen, or a list of cached API responses, into an
option with autoload on, because that cost is paid on every request for pages that never
touch it.
The rule that follows: an option is for configuration, which the plugin needs on most requests and which changes rarely. When you store a settings array, store the fields that the plugin reads, not a copy of the form it rendered. When the value is large or is a cache, it is a transient, and when it is per-object state, it belongs on that object.
A transient for state that must expire on its own
The plugin uses exactly one transient, and it uses it for a problem a transient is right for. When the plugin is activated before WooCommerce is present, it does not install anything. It records an intent and lets a later request act on it:
function sfct_plugin_activate( $network_wide = false ) {
if ( ! sfct_is_woocommerce_active() ) {
set_transient( 'sfct_woocommerce_dependency_redirect', '1', MINUTE_IN_SECONDS * 10 );
return;
}
if ( function_exists( 'sfct_activate' ) ) {
sfct_activate( $network_wide );
}
}
The value is one byte. The behaviour that matters is the ten-minute expiry. A redirect that fires once and then expires is the correct shape for "show the person who activated this a setup screen"; an option would leave a flag on every request until something remembered to delete it, and on a site where the admin never visits the screen again it would sit there indefinitely.
What belongs in a transient: cache entries for expensive computation or a remote call, one-time flags, and anything whose absence is as acceptable as its presence. What does not belong in one: anything you cannot afford to lose at an arbitrary moment. Transients disappear without warning when a cache evicts them, and on a site with a persistent object cache they may never reach the database at all, so a plugin that treats a transient as storage has a bug that only reproduces under memory pressure.
An upgrade routine is two lines of code and one honest question
WordPress stores the database schema version it last installed in the
db_version option, and runs its own upgrade routines when that number falls
behind. The plugin borrowed the shape but not the storage: the lab install contains
db_version, because WordPress put it there, and no plugin option equivalent.
There is no sfct_db_version, no
upgrader_process_complete handler and no version comparison anywhere in the
source. sfct_install_tables() runs when the plugin is activated and never
again.
The bootstrap registers that entry point against activation only:
register_activation_hook( __FILE__, 'sfct_plugin_activate' );
register_deactivation_hook( __FILE__, 'sfct_plugin_deactivate' );
Whatever the hook does, it does when a plugin is activated. WordPress's upgrader fires
upgrader_process_complete and upgrader_post_install, and the
install that replaces a plugin's files does not call activate_plugin(). So a
store that updates from the dashboard never runs this code again. The next release that adds
a column, an index or a table ships that schema to fresh installs and to nobody else, and
the site keeps working on the old shape until the queries that wanted the new column start
returning wrong answers.
A version gate fixes that, and the shape is short enough to keep in your head:
add_action( 'plugins_loaded', 'sfct_maybe_upgrade' );
function sfct_maybe_upgrade() {
if ( SFCT_VERSION === get_option( 'sfct_db_version' ) ) {
return; // the common case: one option read, nothing else
}
sfct_install_tables(); // idempotent, so re-running costs nothing but time
sfct_run_schema_migrations(); // the DROP and RENAME statements dbDelta will not issue
update_option( 'sfct_db_version', SFCT_VERSION, false );
}
Three things make it work. The comparison runs on load, so it repairs a site whose files
were replaced without an activation. sfct_install_tables() is safe to re-run,
which this lesson measured: the second call returned an empty array from both
dbDelta() calls and changed nothing, and an install run against a logs table
holding 5,000 rows left all 5,000 rows in place. And the third argument to
update_option() is false, which turns autoloading off for a value
that no request needs until the version changes. Measured cost of the version-gated path
when nothing has changed: one option read, under a tenth of a millisecond. Measured cost of
the ungated path on the same machine: about 1.7 ms of dbDelta() work every
time, which is survivable but pointless, and grows with the number of tables and columns
you describe.
Write the gate on plugins_loaded rather than init if the
migration has to exist before other plugins query your tables. And treat the migration
function as the place for the statements dbDelta() cannot write: the
DROP COLUMN or DROP INDEX from the previous lesson, a
RENAME, a backfill. Those are the operations that need a stored version,
because unlike dbDelta() they are not idempotent, and running one twice is not
harmless. A DROP COLUMN on a column that is already gone is an error; a
backfill that runs twice doubles the data it was written to move.
Add a version gate to a plugin of your own, then try to break it.
Part one: write myplugin_maybe_upgrade() so it installs tables with
dbDelta(), records the schema version in an option that does not autoload,
and does nothing at all when the stored version is current. Register it on
plugins_loaded.
Part two: prove the gate works. Add a counter to the function, bump the version constant through three fake releases, and load the site once per release. Then load it ten more times without changing the constant.
Part three: break it deliberately. Delete the stored version option on a site whose tables already have the new column, and load the site again. What has to be true about your migration statements for that to be safe?
Show the solution and the reasoning
Part one, in the shape the plugin would need. The option name carries the plugin's prefix so it cannot collide, and autoload is off:
define( 'MYPLUGIN_SCHEMA', '1.2.0' );
add_action( 'plugins_loaded', 'myplugin_maybe_upgrade' );
function myplugin_maybe_upgrade() {
if ( get_option( 'myplugin_schema' ) === MYPLUGIN_SCHEMA ) {
return;
}
myplugin_install_tables();
myplugin_run_migrations();
update_option( 'myplugin_schema', MYPLUGIN_SCHEMA, false );
}
Part two: with three version bumps you should see the work happen three times, once
per release, and the ten extra loads should add nothing. If your counter still climbs
after the constant stops changing, the usual causes are a strict comparison against a
value WordPress returns as a string, an update_option() call that sits above
the early return, or a dbDelta() call placed in the plugin's main file
outside the gated function. The reason to count rather than eyeball it is that all three
of those look correct when you read the code.
Part three is the interesting one, and there is no single right answer. Deleting the
stored version forces the migration to run again on a schema that has already moved, so
every statement has to be written to tolerate that. A
CREATE TABLE IF NOT EXISTS and a dbDelta() call tolerate it by
design. An ADD COLUMN does not, so it needs either
dbDelta() or a check against information_schema before it runs.
A DROP COLUMN does not, so it needs the same kind of check. A backfill is
the one to think hardest about, because the safe version has to know which rows it has
already processed; a WHERE migrated = 0 column, or a per-row marker, turns a
repeated backfill into a no-op instead of a doubling.
The alternative is to make the version option impossible to lose, and you cannot. It lives in the same table as everything else and is restored from the same backups. The right posture is to assume a migration will run twice on a schema that has already changed, write every statement to survive that, and keep the version gate as an optimisation rather than as the thing that makes the migration correct. Once you take that position, the difference between running the install on every activation and gating it stops being about correctness. Both are correct. One of them does the work on every store after every update, and the other does not.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.