The plugin as a program
Guarding on WooCommerce and Failing Well
How a plugin that depends on WooCommerce checks twice, defers its install work, and builds a nonce-protected install button when the dependency is missing.
A store owner opens the Plugins screen and deletes WooCommerce. They are not removing a feature; they are removing the dependency that gives half the installed plugins their reason to exist. WordPress does not ask them to confirm that, and it does not warn the plugins before the next request includes their main files anyway.
That request is where a plugin finds out whether it was written defensively. The fatal error that follows is not a WooCommerce bug and not a WordPress bug. It is a plugin that assumed a dependency. This lesson is the code in this plugin that checks, and the two questions it answers separately: is WooCommerce here, and is it running.
Installed is a file on disk; active is a row in an option
The first helper answers the easy question with a filesystem call.
function sfct_is_woocommerce_installed() {
return file_exists( trailingslashit( WP_PLUGIN_DIR ) . SFCT_WOOCOMMERCE_BASENAME );
}
WP_PLUGIN_DIR is the absolute path of the plugins directory and
SFCT_WOOCOMMERCE_BASENAME is the string woocommerce/woocommerce.php
from the bootstrap. The result is true when the file is there. It says nothing about whether
WooCommerce is running.
That distinction is not academic, and you can watch it change what the plugin registers.
I loaded the bootstrap in a lab harness with the two states set by hand, using WordPress
7.0.7's real add_action() and plugin_basename():
wc_installed=1 wc_active=0 -> 6 hooks registered, sfct_load_textdomain() not defined
wc_installed=1 wc_active=1 -> 41 hooks registered, sfct_load_textdomain() defined
Six hooks is the dependency-mode plugin. Nothing else exists, because the include loop was never reached and none of the fifteen files were read. The plugin has not disabled itself so much as refused to start, and the next section is where that decision gets paid for.
The second helper is longer because the question is harder.
function sfct_is_woocommerce_active() {
if ( ! sfct_is_woocommerce_installed() ) {
return false;
}
$active_plugins = (array) get_option( 'active_plugins', array() );
if ( in_array( SFCT_WOOCOMMERCE_BASENAME, $active_plugins, true ) ) {
return true;
}
if ( is_multisite() ) {
$network_plugins = (array) get_site_option( 'active_sitewide_plugins', array() );
return isset( $network_plugins[ SFCT_WOOCOMMERCE_BASENAME ] );
}
return false;
}
The first line is the cheap check: no option lookup, no multisite query, just the file. Then
get_option( 'active_plugins' ) returns the array of plugin basenames, and
in_array() with strict comparison looks for this one, which is right for an
option read from the database.
The approach hard-codes the directory name of another plugin. Install WooCommerce through
Composer with a renamed directory, or vendor it as my-woocommerce/, and the
check fails and the plugin refuses to start even though WooCommerce is running perfectly.
That assumption is hard to avoid entirely. What is avoidable is treating the file test as the
source of truth: if the symbol you are about to call is what matters,
class_exists( 'WooCommerce' ) or
function_exists( 'wc_get_order_statuses' ) answers the real question, and it
also covers must-use plugins and bundled copies that no option check will find. The file test
earns its place because the plugin needs to tell "installed but inactive" apart from "not
installed" for the button it renders.
A network-activated dependency is not in active_plugins
The multisite branch is the one that gets missed, and it fails in the direction nobody
tests. On a multisite network, a plugin activated for the whole network is stored in the
active_sitewide_plugins site option rather than in any site's
active_plugins. The storage shape is different too: the sitewide option is keyed
by plugin basename, with the activation timestamp as the value, which is why the check is
isset( $network_plugins[ SFCT_WOOCOMMERCE_BASENAME ] ) and not another
in_array().
I reproduced the case in the harness by emptying the per-site option and leaving only the
sitewide one. The plugin still registered its forty-one hooks, which is what should happen. A
version of this function that only checked active_plugins would have found no
include files, registered the six dependency hooks, and shown "Install WooCommerce" to a
network administrator looking at a site where WooCommerce was network-active and working. On
a network of two hundred stores that is not a notice; it is a plugin that turned itself off
everywhere, and the bug does not reproduce on the single-site install where it was written.
The activation wrapper defers work instead of failing
Activation is the one moment when a plugin may do expensive things: create tables, write options, flush rewrite rules. This plugin's activation hook is registered before the dependency guard, so it exists in both states:
/**
* Activation wrapper that keeps the plugin in dependency mode until WooCommerce is ready.
*/
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 );
}
}
Three details carry the weight. The dependency is checked again here rather than trusted
from the request that loaded the page, because activation can arrive through the network
admin or WP-CLI, where the plugin's own guard never ran. The wrapper returns instead of
erroring, so activation succeeds and the plugin sits idle. And the function_exists()
guard around sfct_activate() is the part that looks redundant and is not:
sfct_activate() lives in includes/database.php, which only loads
when the dependency check passes, so calling it unconditionally would be the exact fatal
error this file exists to avoid.
I fired the wrapper directly in the harness to see both outcomes. With WooCommerce active the
call reaches the installer and passes $network_wide through unchanged, which the
real sfct_activate() uses to choose between looping over every site with
get_sites() and installing on the current one. With WooCommerce missing the
installer is never called and the transient is set with a value of '1' and a 600
second expiry.
The transient is a message between two requests
Something has to turn that flag into a screen the administrator sees. It cannot happen on
the activation request, because WordPress fires the activation hook during a request that
redirects before any admin header has been rendered, and an admin_notices
callback registered at that moment prints nothing. The transient carries the intent forward:
function sfct_maybe_redirect_to_woocommerce_required_page() {
if ( ! current_user_can( 'activate_plugins' ) || ! get_transient( 'sfct_woocommerce_dependency_redirect' ) ) {
return;
}
delete_transient( 'sfct_woocommerce_dependency_redirect' );
wp_safe_redirect( admin_url( 'admin.php?page=' . SFCT_ADMIN_MENU_SLUG ) );
exit;
}
The handler is registered on admin_init, which runs early enough to redirect
and late enough that the current user exists, so the capability check means something. It
deletes the transient before redirecting, which makes this a one-shot event rather than a
redirect loop, and wp_safe_redirect() validates the destination.
Two weaknesses are worth stating plainly. The transient key is not per-user, so an
administrator who activates the plugin without WooCommerce arms a redirect that a different
administrator will consume if they load an admin page within ten minutes. Prefixing the key
with get_current_user_id() fixes it for one function call. The ten-minute window
is also a guess. Neither is a security problem, but both make the plugin slightly
unpredictable for the person using it.
The install button is built from core's own nonce actions
The screen the administrator lands on offers one primary action, and it has to be correct in two different worlds: WooCommerce is absent and can be installed from the directory, or WooCommerce is present but inactive and can be activated.
function sfct_get_woocommerce_cta_url() {
if ( sfct_is_woocommerce_installed() ) {
return wp_nonce_url(
self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( SFCT_WOOCOMMERCE_BASENAME ) ),
'activate-plugin_' . SFCT_WOOCOMMERCE_BASENAME
);
}
return wp_nonce_url(
self_admin_url( 'update.php?action=install-plugin&plugin=woocommerce' ),
'install-plugin_woocommerce'
);
}
The nonce actions are not invented; they are the strings core's own handlers check.
wp-admin/plugins.php calls
check_admin_referer( 'activate-plugin_' . $plugin ) before activating, and
wp-admin/update.php calls check_admin_referer( 'install-plugin_' .
$plugin ) before installing. Anything else produces a button that leads to "You are
not allowed to access this page". self_admin_url() keeps the flow on the network
admin when the plugin is managed from there, and rawurlencode() handles the
slash in the basename.
The notice that renders this button escapes its bold title with printf() and
esc_html__(), so the <strong> tags in the format string
survive while the translated text cannot inject markup. Escape the values, not the format.
That is correct and it reads wrong, which is a good reason to have someone else look at a
template before it ships.
Requires Plugins does not replace any of this
The plugin header declares Requires Plugins: woocommerce, a header the Plugin
Handbook describes as a comma-separated list of wordpress.org slugs, naming
my-plugin rather than my-plugin/my-plugin.php. WordPress 7.0 reads
it at activation: activate_plugin() calls
validate_plugin_requirements(), which parses the header fields through
get_plugin_data() and, when
WP_Plugin_Dependencies::has_unmet_dependencies() reports the dependency missing
or inactive, returns a WP_Error with the error code
plugin_missing_dependencies. Activation stops, and a warning notice appears on
the Plugins and Add Plugins screens from
display_admin_notice_for_unmet_dependencies().
What the header cannot do
The check happens during the activation request and nowhere else. Deactivate WooCommerce an hour later and WordPress does not re-read the header on the next page load; it includes this plugin's main file the same way it includes every active plugin. Every line of the dependency guard exists to serve that request, and none of it is made redundant by the header.
Which raises the question of whether the plugin should declare the header at all. It already
handles a missing WooCommerce about as well as a plugin can: activation succeeds, a menu
appears, a notice explains the situation, and the button leads to a nonce-protected install
or activate URL. Adding Requires Plugins means WordPress refuses that activation,
so the guidance screen and its button are unreachable while the plugin is inactive. The
dependency handling it wrote cannot run until the dependency is already satisfied. I would
drop the header here, or drop the guidance screen and rely on core. Right now the plugin does
both, and the two mechanisms disagree about whether a site without WooCommerce is a state
worth entering.
The pattern this file exists to avoid is short and common. A plugin declares
Requires Plugins, or states in its readme that WooCommerce is required, and then
calls WooCommerce functions from plugins_loaded with no guard. I wrote that
plugin, forty words long, and ran it with the dependency absent:
add_action( 'plugins_loaded', 'demo_on_plugins_loaded' );
function demo_on_plugins_loaded() {
$statuses = wc_get_order_statuses();
echo count( $statuses ), " statuses\n";
}
PHP Fatal error: Uncaught Error: Call to undefined function wc_get_order_statuses() in demo-unguarded.php:11
Stack trace:
#0 class-wp-hook.php(341): demo_on_plugins_loaded('')
There is no partial rendering and no recovery mode for a front-end request. The visitor gets a 500 and the owner gets a blank page with nothing pointing at the plugin that removed WooCommerce. It is the cheapest failure in WordPress to prevent and one of the most common to ship, because it never happens on the machine where the plugin was written.
Write a fifty-line plugin whose main file depends on WooCommerce but which can be activated on a site where WooCommerce is absent, present but inactive, and active. Give it a guard function that checks the installed state and the active state, including the multisite sitewide list, a top-level early return when the dependency is not active, and an activation callback that sets a transient instead of doing work.
Then run it in all three states with a harness like the one this lesson used, or with
wp plugin deactivate woocommerce and a browser, and write down the fatal
error you get if you remove the early return. The error text is the deliverable, not the
code.
Show the reasoning behind the answers
The skeleton is deliberately the same shape as the plugin's: constants, two checks, a guard, an activation wrapper.
define( 'DEMO_DEPENDENCY', 'woocommerce/woocommerce.php' );
function demo_dependency_installed() {
return file_exists( trailingslashit( WP_PLUGIN_DIR ) . DEMO_DEPENDENCY );
}
function demo_dependency_active() {
if ( ! demo_dependency_installed() ) {
return false;
}
if ( in_array( DEMO_DEPENDENCY, (array) get_option( 'active_plugins', array() ), true ) ) {
return true;
}
if ( is_multisite() ) {
$network = (array) get_site_option( 'active_sitewide_plugins', array() );
return isset( $network[ DEMO_DEPENDENCY ] );
}
return false;
}
register_activation_hook( __FILE__, 'demo_activate' );
function demo_activate() {
if ( ! demo_dependency_active() ) {
set_transient( 'demo_dependency_missing', '1', MINUTE_IN_SECONDS * 10 );
return;
}
// Install work that needs WooCommerce goes here.
}
if ( ! demo_dependency_active() ) {
add_action( 'admin_notices', 'demo_dependency_notice' );
return;
}
// Everything below this line may call WooCommerce functions.
function demo_dependency_notice() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html__( 'This demo plugin needs WooCommerce.', 'demo' )
);
}
The ordering inside the file is the part people get wrong. The activation hook is
registered before the guard, because activation has to work while the dependency is
missing. The guard returns before any file that calls WooCommerce is included, and the
notice is registered inside that branch so it never fires on a working site. Put the
register_activation_hook() call below the return and the plugin
becomes uninstallable in exactly the state where the owner needs to install its dependency.
There is more than one defensible guard condition. is_plugin_active() reads
better than the option lookup and is available when plugins load in WordPress 7.0, where
wp-settings.php requires wp-admin/includes/plugin.php before it
includes any plugin file. class_exists( 'WooCommerce' ) survives a renamed
directory and covers a dependency that a must-use plugin boots. Pair either one with the
file check when you need to choose between "Install" and "Activate" on a screen, because a
missing class cannot tell you which of those two is the right instruction.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.