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

The plugin as a program

Reading a Plugin From the Outside In

The plugin header, the constants and the hook registrations tell you what a plugin does before you run it. A method for reading unfamiliar code in the right order.

You inherit a plugin directory. There are eight thousand lines of PHP across eighteen files, and the person who wrote it has left. The instinct is to start at the top of the first file and read downward, which is the slowest possible way to learn anything about it. Two hours later you know the name of every helper function and still cannot say what happens when a customer loads a tracking page.

The Sifero Courier Timeline for WooCommerce plugin is the subject of this course, and it is a useful one to practise on because it is a real plugin with a real job. Its main file is 288 lines and the rest of the plugin is 7,804 lines behind it. Before you read any of that, the main file answers four questions: what does this plugin declare about the environment it needs, what does it name, what does it load, and where does it attach itself to WordPress. Answer those in order and the rest of the code becomes a reference you consult rather than a document you read.

The header describes the environment the plugin expects to find

sifero-courier-timeline-for-woocommerce.php opens with a docblock that the web server, WordPress and the plugin directory all read. WordPress extracts it with get_file_data(), which applies a regular expression to the first 8 KB of the file. A header field below that mark is invisible, which is why plugin headers sit at the top even when the author would rather start with a namespace declaration.

/**
 * Plugin Name: Sifero Courier Timeline for WooCommerce
 * Description: Courier timeline, delivery companies, order tracking and public shipment status pages for WooCommerce stores.
 * Version:     1.2.0
 * Author:      WPSifero
 * Author URI:  https://wpsifero.com
 * Text Domain: sifero-courier-timeline-for-woocommerce
 * Domain Path: /languages
 * Requires at least: 6.2
 * Requires PHP: 8.0
 * Requires Plugins: woocommerce
 * WC requires at least: 7.0
 * WC tested up to: 9.0
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 *
 * @package SiferoCourierTimelineForWooCommerce
 */

Four of those lines are requirements, and each one has a different enforcement story. The distinction matters because plugin authors, including this one, sometimes treat all four as guarantees. They are not.

HeaderValueWho enforces it, and when
Requires at least 6.2 WordPress, at activation. It does not stop the file loading on a site that was later downgraded.
Requires PHP 8.0 WordPress, at activation. PHP itself has no idea the header exists.
Requires Plugins woocommerce WordPress 6.5 and newer, at activation, through validate_plugin_requirements().
WC requires at least 7.0 WooCommerce, when it scans plugins. WordPress ignores it entirely.

Read the fourth row again, because it is the trap. WC requires at least and WC tested up to are WooCommerce's own headers. Nothing in WordPress core parses them, and nothing in this plugin does either. They describe a tested configuration to a human and to the plugin directory, and they are the plugin's way of saying "this was current when we shipped". A store running WooCommerce 6.4 will activate this plugin without a warning from either plugin.

What the header cannot promise

All four requirement headers are checked when the plugin is activated. A plugin header is not a contract that survives the activation request. If an administrator deactivates WooCommerce next month, WordPress does not re-read this header, and the plugin's main file is loaded on the next request anyway.

Requires Plugins deserves a precise description because it is the most useful of the four and the most misunderstood. Core's activation path calls validate_plugin_requirements(), which reads the header and asks WP_Plugin_Dependencies::has_unmet_dependencies(). If WooCommerce is missing or inactive, activation fails with a message naming the missing plugin. That is a real protection and it costs you one line. It is not the same as having a runtime guard, and the third lesson in this module shows the code that covers the difference.

Every file starts by refusing to run outside WordPress

Immediately after the header, the plugin does the least glamorous important thing in WordPress: it exits.

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

ABSPATH is defined in wp-load.php before any plugin loads, so inside a WordPress request the condition is false and execution continues. Request wp-content/plugins/sifero-courier-timeline-for-woocommerce/sifero-courier-timeline-for-woocommerce.php directly with a browser and PHP runs the file without WordPress having loaded anything. Without the guard, an attacker who can guess the path gets a request that executes the plugin's top-level code in an environment with no WordPress functions, and every line that follows the header becomes reachable on demand.

The guard does not stop that request; it stops the file from doing anything at all before it ends. That is the point, and it is why the Plugin Handbook's security chapter opens with it. All eighteen PHP files in this plugin carry the guard, including the two templates, which is the standard you should hold your own code to:

grep -rl "defined( 'ABSPATH' )" . --include='*.php' | wc -l
# 18
find . -name '*.php' | wc -l
# 18

Constants turn the file's own location into names

The next block is where the plugin stops being a file and starts being a program with an address.

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' );

Two of these are worth noticing while you are still reading. SFCT_VERSION repeats the Version header as a PHP constant, which means the version exists in two places and can drift. SFCT_WOOCOMMERCE_BASENAME is the hard-coded relative path of another plugin, and it is the hinge the entire dependency guard swings on. The second lesson takes both apart.

The includes array and the hook block are the entry points

Halfway down the file, after the guard functions that lesson 3 covers, comes an array of fifteen relative paths and a loop that loads them:

$sfct_includes = array(
	'includes/database.php',
	'includes/languages.php',
	'includes/statuses.php',
	// ... ten more ...
	'includes/emails.php',
);

foreach ( $sfct_includes as $sfct_include ) {
	require_once SFCT_PATH . $sfct_include;
}
unset( $sfct_include, $sfct_includes );

That array is a table of contents. If you want to know where a feature lives, find the file whose name matches it and open that file, rather than searching eight thousand lines. When I ran the bootstrap with WooCommerce present, PHP loaded sixteen plugin files in this order:

 1. sifero-courier-timeline-for-woocommerce.php
 2. includes/database.php
 3. includes/languages.php
 4. includes/statuses.php
 5. includes/orders.php
 6. includes/assets.php
 7. includes/templates-frontend.php
 8. includes/admin-pages.php
 9. includes/companies.php
10. includes/woocommerce.php
11. includes/tracking.php
12. includes/shortcodes-core.php
13. includes/shortcodes.php
14. includes/ajax.php
15. includes/rest-api.php
16. includes/emails.php

Below that, the file attaches itself to WordPress. This is the part most readers skip, and it is the part that tells you what the plugin actually does:

add_action( 'plugins_loaded', 'sfct_load_textdomain' );
add_action( 'init', 'sfct_ensure_default_logistics_statuses', 5 );
add_action( 'init', 'sfct_register_order_statuses' );
add_action( 'init', 'sfct_add_rewrite_rules' );
add_action( 'template_redirect', 'sfct_template_redirect' );
add_action( 'admin_menu', 'sfct_admin_menu' );
add_action( 'rest_api_init', 'sfct_register_rest_routes' );
add_action( 'woocommerce_checkout_order_created', 'sfct_maybe_generate_tracking_on_order_created_object', 10, 1 );
add_action( 'wp_ajax_nopriv_sfct_public_lookup', 'sfct_ajax_public_lookup' );

Read the hook names as a sentence and the plugin introduces itself. There is a public route that bypasses the normal template (template_redirect plus a rewrite rule), an admin menu, three REST routes, tracking generated when a WooCommerce order is created, and one AJAX endpoint reachable without logging in. That last one is the only endpoint in the plugin a stranger can call, and it is the first thing I would look at in a review. Nothing in the folder layout tells you that; the hook list does.

Everything on that list is a registration, not a call. When the plugin file finishes loading, no tracking ID has been generated, no table has been created, and no screen has been rendered. The plugin has declared what it wants to be told about, and WordPress will tell it later. That distinction is the difference between reading a plugin and running one, and it is the subject of the next lesson.

Lab 25 minutes

Pick a plugin on a local WordPress install that you did not write. Open only its main file and produce a one-page brief with four sections: the requirement headers and what each one actually enforces, every constant it defines, the list of files it loads, and its hook registrations grouped by hook name. Do not open any other file yet.

Then answer three questions in writing:

1. Which of the requirement headers would still hold if an administrator downgraded the site a month after activation?

2. Does every PHP file in the plugin carry an ABSPATH guard? Check with a command, not by reading.

3. Which registered hook is the earliest one at which the plugin does work, and which is the latest? That pair bounds everything the plugin can do.

Show the reasoning behind the answers

The four sections of the brief are a reading order, not a format. The header tells you what the author believed about the environment; the constants tell you what vocabulary the rest of the code shares; the include array tells you where to look; the hooks tell you when the code runs. If you find yourself opening includes/ before you have the hook list, you are reading a dictionary when you wanted a map.

For the first question, none of them hold. Requires at least, Requires PHP and Requires Plugins are all evaluated during the activation request, and WordPress does not re-check them on later requests. A site restored from a backup that predates the plugin's requirements, or a multisite where one site's PHP was pinned to an older version, will load the plugin anyway. That is not a criticism of core: re-reading every plugin header on every request would cost more than it prevents. It is the reason the runtime guard exists.

For the second, use the file test rather than your eyes, because the interesting case is the file you did not open:

cd wp-content/plugins/your-plugin
find . -name '*.php' | wc -l
grep -rl "defined( 'ABSPATH' )" . --include='*.php' | wc -l

If the numbers differ, list the difference. Templates, partials and files inside a framework directory are the usual omissions, and they are exactly the files a theme or a direct request can reach. In this plugin the two numbers are both eighteen, because the author applied the guard to templates/frontend-template.php and templates/orders-frontend-1.php as well, and those are the files most likely to be included from somewhere else.

The third question has a mechanical answer. Sort the hooks by when WordPress fires them, using the Action Reference in the Plugin Handbook, and read off the first and last. Here the earliest work happens on before_woocommerce_init, where the plugin declares compatibility with WooCommerce's custom order tables, and the latest is woocommerce_order_status_changed, when a status change sends an email. Every behaviour the plugin has lives between those two points. A hook that does not appear in the main file is registered from inside an include, which is why the second pass through the plugin is a search for add_action and add_filter across the whole tree rather than a read of the main file alone.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.