Skip to content
System nominalUTC --:--:--Articles 080
MODULE 01 Lesson 02 / 18 30 MIN

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:

ConstantValue at runtime
SFCT_FILE/path/wp-content/plugins/sifero-courier-timeline-for-woocommerce/sifero-courier-timeline-for-woocommerce.php
SFCT_BASENAMEsifero-courier-timeline-for-woocommerce/sifero-courier-timeline-for-woocommerce.php
SFCT_PATH/path/wp-content/plugins/sifero-courier-timeline-for-woocommerce/
SFCT_URLhttps://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.

Lab 30 minutes

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.