The plugin as a program
The Bootstrap File and the Order It Loads In
Nine constants derived from the plugin file's own location, fifteen files loaded every request, and why load order is decided before plugins_loaded fires.
The main file of this plugin is 288 lines. WordPress includes it on every request, including the requests that have nothing to do with shipping: a cron ping, a REST call from a mobile app, a 404 for a missing image. Whatever that file does at the top level, it does for all of them, and the fifteen files it requires come along for the ride.
Nothing in the file is accidental, though. The constants make the plugin's location into a vocabulary the rest of the code can use without knowing where the plugin is installed. The include loop decides which features exist on a request, and the hook registrations decide when they run. That order is the design of the plugin.
The constants are derived, not configured
Nine define() calls sit between the ABSPATH guard and the first
function. They are not settings; a settings value is something a site owner can change. These
are facts about where the file is, computed once.
define( 'SFCT_VERSION', '1.2.0' );
define( 'SFCT_FILE', __FILE__ );
define( 'SFCT_BASENAME', plugin_basename( __FILE__ ) );
define( 'SFCT_PATH', plugin_dir_path( __FILE__ ) );
define( 'SFCT_URL', plugin_dir_url( __FILE__ ) );
define( 'SFCT_TEXT_DOMAIN', 'sifero-courier-timeline-for-woocommerce' );
define( 'SFCT_ADMIN_MENU_SLUG', 'sifero-courier-timeline-for-woocommerce' );
define( 'SFCT_OPTIONAL_ADDON_URL', 'https://wpsifero.com/sifero-courier-timeline-for-woocommerce' );
define( 'SFCT_WOOCOMMERCE_BASENAME', 'woocommerce/woocommerce.php' );
I loaded this file against WordPress 7.0.7's own plugin_dir_path() and
plugin_dir_url() to see the values rather than guess at them, with
WP_PLUGIN_DIR pointing at a lab tree:
| Constant | Value at runtime |
|---|---|
SFCT_FILE | /path/wp-content/plugins/sifero-courier-timeline-for-woocommerce/sifero-courier-timeline-for-woocommerce.php |
SFCT_BASENAME | sifero-courier-timeline-for-woocommerce/sifero-courier-timeline-for-woocommerce.php |
SFCT_PATH | /path/wp-content/plugins/sifero-courier-timeline-for-woocommerce/ |
SFCT_URL | https://example.test/wp-content/plugins/sifero-courier-timeline-for-woocommerce/ |
SFCT_FILE is an absolute filesystem path and SFCT_BASENAME is the
path relative to the plugins directory. They do different jobs.
SFCT_BASENAME is what WordPress uses to identify a plugin in the
active_plugins option, which is why it is the string behind
plugin_action_links_ and why lesson 3 checks another plugin's basename the same
way. SFCT_FILE goes to WooCommerce's
FeaturesUtil::declare_compatibility(), which wants a file path, not a URL.
Three of the constants are not derived from the location at all. The text domain, the admin
menu slug and the shop URL for the optional add-on are names that would otherwise be repeated
as literals. Two more, SFCT_PLUGIN_DIR and SFCT_PLUGIN_URL, are
defined near the bottom of the file inside a defined() check, and nothing in the
tree reads either one. They duplicate SFCT_PATH and SFCT_URL under
different names, most likely kept for a child plugin that referenced them once.
Why plugin_dir_path rather than a relative path
The obvious way to include a file is require_once dirname( __FILE__ ) .
'/includes/assets.php';. The SFCT_PATH version does the same job through
a core function, and the difference is not cosmetic. Core's implementation is three lines:
function plugin_dir_path( $file ) {
return trailingslashit( dirname( $file ) );
}
trailingslashit() is the part that matters. It guarantees the returned path
ends in a single forward slash, so SFCT_PATH . 'includes/assets.php' is always
correct and never depends on the author remembering a separator. The same holds for
SFCT_URL: plugin_dir_url() wraps plugins_url() and
then calls trailingslashit(), so
SFCT_URL . 'assets/css/frontend-style.css' cannot produce a doubled or missing
slash.
There is a second reason that does not show up until a site moves.
plugins_url() builds the URL from WP_PLUGIN_URL and the plugin's
basename, then passes the result through set_url_scheme(). The scheme therefore
follows the site: an HTTPS admin on a site whose stored URL is HTTP still gets an HTTPS asset
URL. A hard-coded URL gets that wrong on exactly the sites you cannot debug, the ones behind
a proxy or with a forced SSL admin.
The file loads before plugins_loaded, and everything depends on that
Every active plugin file is included by wp-settings.php in a loop, and only
after the loop finishes does WordPress fire plugins_loaded. In WordPress 7.0.7
the sequence around that point is:
wp-settings.php:574 foreach ( wp_get_active_and_valid_plugins() as $plugin ) { ... }
wp-settings.php:622 do_action( 'plugins_loaded' );
wp-settings.php:697 do_action( 'setup_theme' );
wp-settings.php:749 do_action( 'after_setup_theme' );
wp-settings.php:771 do_action( 'init' );
The plugin's include loop sits inside the loop on the first line. It is not inside a
callback, it is not deferred, and it does not need to be: WordPress includes a plugin file
once, after the plugin API is loaded, and by then plugin_basename(),
plugin_dir_path() and add_action() all exist. The file body is the
plugin's load phase.
That placement is why the plugin can register sfct_load_textdomain on
plugins_loaded and know the function exists. The loader in
includes/languages.php calls load_textdomain() with an explicit
.mo path rather than relying on core's just-in-time textdomain loader, which
since WordPress 6.7 warns when a domain is resolved before after_setup_theme has
run.
The tempting alternative is to move the include loop into a plugins_loaded
callback so the plugin loads "at the right time". That buys nothing. The file has already
been read and compiled, so no work is saved, and any function another file registers is now
defined later than the callback that wants it. Load files at file scope. Reserve
plugins_loaded for work that has to happen after every plugin's files exist but
before the theme does, and init for work that needs the query, the current user
or a registered post type. after_setup_theme sits between them and runs after
the theme's functions.php, which is the first point at which theme support
declarations are complete.
Loading every file on every request has a price you can measure
The include array has fifteen entries and no conditions. WooCommerce is present or the plugin has already returned, so the only branch is at the top of the file. That is the whole optimisation strategy: either the dependency is there and everything loads, or it is not and nothing but the guard functions loads.
The price is parsing. I measured it in a lab harness that loads the plugin's real files with a stub layer standing in for the WordPress functions they call, on PHP 8.5.6 with opcache off in the CLI:
main file alone: 0.3 ms
the 15 include files: 4.2 - 7.2 ms (five runs, 4.5 ms mean)
Treat that as an order of magnitude, not a benchmark. The harness excludes everything a real request does, opcache was off, and a warm bytecode cache removes most of the parse cost. The number is still useful because it is the cost of the decision rather than the cost of WordPress. On a single site with twenty plugins this is noise. On a shared host, it is the kind of thing that shows up in a slow-request report with no obvious culprit.
What I would not do is move the includes behind init to "only load what is
needed". Splitting them by context is the honest version of that idea: the admin files behind
is_admin(), the frontend files behind the hook that renders them. That is a real
change, with a real cost in complexity, because a function used from both sides now has to be
findable from both. The plugin did not take that trade, and for one feature set it is
defensible.
SFCT_URL and SFCT_VERSION are what make assets work later
The two constants that earn their place on the first request are the two that appear in
includes/assets.php, the file that registers the plugin's styles and scripts:
function sfct_register_frontend_assets() {
wp_register_style( 'sfct-frontend', SFCT_URL . 'assets/css/frontend-style.css', array(), SFCT_VERSION );
wp_register_script( 'sfct-frontend', SFCT_URL . 'assets/js/frontend-app.js', array(), SFCT_VERSION, true );
}
The fourth argument to wp_register_style() and
wp_register_script() is a version string, and WordPress appends it to the URL as
a query argument. That is the whole cache-busting mechanism: change the version and every
browser that cached the file asks for it again, because the URL changed. Using the constant
rather than the literal keeps the asset version and the plugin version together. Six call
sites in this file use SFCT_VERSION, including the admin styles and two
conditional admin scripts.
SFCT_URL has a second consumer outside the enqueue functions, in the frontend
template that renders the shipment page logo. That is what makes a constant better than
repeating plugins_url() at each use site: one place to change, one place to be
wrong.
The weak point is that SFCT_VERSION is a hand-maintained string. The version
exists in the plugin header and in the define(), and nothing synchronises them.
Release 1.2.1 that updates the header and forgets the constant reports one version in the
directory and ships assets under another, so browsers keep the old CSS while the admin screen
says the new version is installed. If the constant came from the header, read once with
get_file_data(), that class of bug would not exist.
Take any plugin on a local install, including this one, and answer three questions with commands rather than by reading:
1. How many PHP files does the main file cause to load on a request where its dependency is active, and how many on a request where it is not? For this plugin the answers are 16 and 1. Find a way to observe both without editing the plugin.
2. Change one constant and predict what breaks before you run anything. Use
SFCT_VERSION, bump it in the define() only, and describe what a
browser and a site owner each see. Then revert it.
3. Measure the cost of the include loop in a request of your own. If you have opcache available, measure with it on and off, and write down which number a host would actually see.
Show the reasoning behind the answers
For the first question, the guard is the thing to observe, and it does not need a
debugger. Deactivating WooCommerce on a local install and watching for the
sfct_ admin menu is enough to see the branch, because that menu is registered
from inside the guard. A normal request reads sixteen files: the main file plus the fifteen
in the array. With WooCommerce absent the count is one, and six hooks are registered
instead of forty-one.
The second question is where most readers learn something. Bumping
SFCT_VERSION to 1.2.1 and reloading an admin page changes the
?ver= argument on six styles and scripts, so a browser fetches them again. It
also changes the version printed on the plugin's own info panel, because
includes/admin-pages.php echoes SFCT_VERSION directly. What it
does not change is the plugin header, so the Plugins screen still says 1.2.0 and an update
check would offer to "update" the site to the version it already has.
The third question has no single right answer, and the honest one is to trust your own measurement over mine. If opcache is on with enough memory, the include files are compiled once and the per-request cost falls to a few file lookups. If it is off, or the files are evicted because fifty other plugins fill the cache, you pay the parse every time. The lab that matters is the one on the host you deploy to.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.