Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 10 / 18 30 MIN

The public side

Shortcodes, Templates and Overrides

The plugin registers six shortcode tags that share three callbacks, then resolves a template slug rather than calling locate_template. What that costs a theme.

Open the plugin on a fresh install and the public tracking page works: the shortcode renders a search form, the form posts a tracking code, the timeline appears. Then you try to restyle the timeline from your child theme, copy templates/frontend-template.php into the theme the way you would copy a WooCommerce template, and nothing changes. No warning, no fallback, the same output.

The plugin's own documentation claims a theme can override that file. The code does not call locate_template() and it does not call wc_get_template(). A grep of all 1,800 lines of frontend code for get_stylesheet_directory returns nothing. What it calls instead is a slug resolver, and once you can read that resolver you know which knobs exist and which ones you will have to add yourself.

Six shortcode tags, three callbacks

Registration happens on init at priority 20, which is early enough to be there before any content renders and late enough for the other includes to have loaded. The tags and callbacks are not registered one by one; they come out of a map:

function sfct_register_shortcodes() {
	foreach ( sfct_get_shortcode_callbacks() as $tag => $callback ) {
		add_shortcode( $tag, $callback );
	}
}

That map is worth reading as a design statement, because the six tags are three features with three aliases:

TagCallbackWhat it renders
sfct_trackingsfct_shortcode_public_trackingThe public search form and, given a code, the timeline
sfct_public_trackingsfct_shortcode_public_trackingAlias of the above
sfct_customer_orderssfct_shortcode_customer_ordersThe logged-in customer's orders with tracking
sfct_my_orderssfct_shortcode_customer_ordersAlias of the above
sfct_order_trackingsfct_shortcode_order_trackingOne order, by order ID, tracking ID or order key
sfct_tracking_buttonsfct_shortcode_tracking_buttonA link to the public tracking URL

Two of the aliases exist so the admin screen can advertise a friendlier name without breaking pages that already use the older tag. That is cheap and correct, but it means shortcode_exists() answers yes for both spellings. Removing an alias later removes a public API, so the aliases stay.

You can confirm the registration on your own install without loading a page:

wp eval 'global $shortcode_tags; echo isset( $shortcode_tags["sfct_tracking"] ) ? $shortcode_tags["sfct_tracking"] : "missing";'
sfct_shortcode_public_tracking

The third argument to shortcode_atts is a filter name

Every callback normalises its attributes first. The public one does it in six lines:

$atts = shortcode_atts(
	array(
		'tracking_id' => '',
	),
	(array) $atts,
	'sfct_tracking'
);

The first argument is the defaults, the second is what the author actually typed, and the third is the shortcode tag. That third value is not documentation. Inside shortcode_atts() in WordPress 7.0.7 it is the suffix of a dynamic filter:

$out = apply_filters( "shortcode_atts_{$shortcode}", $out, $pairs, $atts, $shortcode );

So the line above creates shortcode_atts_sfct_tracking running with four arguments, the last of which is the tag itself. Miss it or pass an empty string and the filter disappears. Pass the wrong tag and you get a filter nobody expects.

Two ways this plugin's attributes surprise you

First, [sfct_tracking] filters twice. The wrapper sfct_shortcode_public_tracking() calls shortcode_atts() and then hands the result to sfct_tracking_shortcode() in includes/tracking.php, which calls it again with the same tag. Your callback runs on both passes. That is harmless if your filter is idempotent and surprising if it counts or logs, so write it as a pure function of the values you are given.

Second, the alias [sfct_public_tracking] still reports the tag sfct_tracking, because the third argument is hardcoded rather than derived from the tag that invoked the callback. Filtering shortcode_atts_sfct_public_tracking does nothing at all. A callback registered under two tags cannot know which one was used unless it derives that itself, and this one does not try.

Every callback returns a string, and none of them echo

The rule is easy to state and easy to break: do_shortcode() replaces the tag in the content with whatever the callback returns. Anything the callback echoes goes straight to the browser at the point the content filter runs, which in a classic theme is inside the post body and in a block theme may be somewhere you did not plan.

This plugin gets it right everywhere, and it gets there by buffering the template rather than trusting it. sfct_tracking_shortcode() ends like this:

ob_start();
$template = function_exists( 'sfct_resolve_public_tracking_template' ) ? sfct_resolve_public_tracking_template() : SFCT_PATH . 'templates/frontend-template.php';
include $template;
return ob_get_clean();

You can verify the contract on your install rather than taking my word for it. Call the shortcode with the buffer watching:

ob_start();
$returned = do_shortcode( '[sfct_tracking]' );
$echoed = ob_get_clean();
printf( "returned %d bytes, echoed %d bytes\n", strlen( $returned ), strlen( $echoed ) );

On a clean install with no tracking record to find, that prints returned 1836 bytes, echoed 0 bytes. The markup came back as a value. A shortcode that echoes still looks right on the front end until something else consumes the output: a REST response, a block template, a feed, or another plugin calling do_shortcode() for an excerpt. Then the text appears at the top of the page and in the wrong place.

The template reads variables you did not pass

templates/frontend-template.php opens with $sfct_result_only = isset( $sfct_result_only ) ? ... : false; and then uses $payload and $tracking_id. Those exist because include runs in the scope of sfct_tracking_shortcode(). It works, and it is why the default template needs no arguments. It also means any variable with the same name in that function leaks into the template. Here the names are unique enough in practice; in a template you wrote, prefix every variable.

The plugin resolves a template slug, not a theme file

Here is the honest answer to the override question. The plugin does not have a template hierarchy. It has a catalog. sfct_get_template_type_config() is the whole configuration. For the public side it returns a default slug of default-public, a default path of SFCT_PATH . 'templates/frontend-template.php', a layout directory under templates/layout/public, and a custom directory built by sfct_get_custom_templates_dir(), which is wp-content/uploads/sifero-courier-timeline-for-woocommerce/templates/public. The order type is the same shape, ending at templates/orders-frontend-1.php.

sfct_get_template_catalog_by_type() then builds the map. First the default entry under the key default-public or default-order. Then sfct_scan_template_directory() over the layout directory, keeping PHP files that carry an SFCT Template Name header, and over the custom directory, keeping HTML files that carry the same header. At the end, sfct_resolve_template_from_slug() looks the chosen slug up in that map and returns its path, or the default path when the slug is empty, unknown, or the file has since been deleted.

On a stock install the catalog has exactly one entry per type. The scanner finds nothing, because templates/layout/public does not exist in the shipped plugin and neither file in templates/ carries the header. So the sequence at runtime is: read the sfct_template_settings option, look the slug up in a map with one key, return the bundled file. Print the answer yourself:

wp eval 'echo sfct_resolve_public_tracking_template(), "\n"; echo implode( ", ", array_keys( sfct_get_public_template_catalog() ) ), "\n";'
/var/www/html/wp-content/plugins/sifero-courier-timeline-for-woocommerce/templates/frontend-template.php
default-public

The guard that makes a theme override impossible

Before including a PHP template, sfct_render_public_template_markup() and sfct_render_order_template_markup() check sfct_is_plugin_managed_php_template(), which is an extension check plus a path check: the resolved file must sit inside SFCT_PATH. Fail it and the function quietly substitutes the default path. Both scanned directories are inside the plugin, so no code path can produce a path in get_stylesheet_directory().

Credit where it is due: that guard is the right instinct. It stops a template setting from becoming an arbitrary file include, which is a real vulnerability in plugins that store a template path in an option and include it. The cost is that the theme is excluded by design. A theme can style the output, because the markup carries classes like sfct-card and sfct-dp2-search, and it can filter the HTML through WordPress's standard output filters. It cannot replace the template.

What the override order would look like if the plugin had one

The accepted WordPress pattern is to search a list of paths and take the first match. WooCommerce wraps it in wc_get_template(), which checks yourtheme/woocommerce/ before the plugin's own templates/ directory. The core primitive underneath is locate_template():

$template = locate_template(
	array(
		'sifero-courier-timeline/frontend-template.php',
	)
);

if ( ! $template ) {
	$template = SFCT_PATH . 'templates/frontend-template.php';
}

include $template;

locate_template() loops the names you give it through get_stylesheet_directory() first and get_template_directory() after, and returns the first file that exists. A child theme wins because it is consulted before its parent, which is the entire point of the child theme mechanism: the parent stays updatable while the child holds the differences. Returning an empty string means no theme file was found, and you fall back to the plugin copy.

Where the plugin instead offers a supported extension point is the catalog filter. sfct_get_shortcode_catalog() returns through apply_filters( 'sfct_shortcode_catalog', $catalog ), so the admin list of shortcodes is extensible even though the shortcode registration itself is closed. If you were adding overrides today you would add a filter in the same place: build the candidate array, filter it so a theme can prepend its own path, then locate_template() it. You would keep the plugin-directory guard for anything that is not a theme path, and you would keep the default as the last resort rather than the first.

Lab 35 minutes

Prove the lookup to yourself, then add the missing one.

First, on your own WordPress 7.0 with the plugin active, print the resolved public template path and the keys of the public catalog, and confirm the catalog holds one entry. Then set sfct_template_settings so public_template is a slug that does not exist, print the resolved path again, and explain what happened to it.

Second, write a sfct_get_public_template() function that searches, in this order: your child theme at sifero-courier-timeline/frontend-template.php, the parent theme at the same relative path, then the plugin's templates/frontend-template.php. Create the child theme file with different visible text, then call your function and confirm it returns the child theme path. Delete the child file and confirm it falls through to the parent, then to the plugin.

Finally, answer in one paragraph: this plugin can already be restyled through CSS. What can a theme override give you that CSS cannot, and what does the plugin lose by not allowing it?

Show the solution and the reasoning

The catalog has one key, default-public, and the resolved path is the bundled file. Change the setting to a slug that does not exist and the resolved path stays the bundled file. That is not a bug hunt; it is sfct_resolve_template_from_slug() doing exactly what it says. It reads $catalog[ $slug ]['path'], finds nothing, and returns $config['default_path']. An unknown setting degrades to the default rather than to a blank page, which is the right failure mode for a public tracking page. The bad slug also stays in the database until an admin saves the screen again, so the setting and the behaviour can disagree for a while.

For the second half, the function is small because locate_template() does the work. The version below takes an optional file name so you can reuse it:

function sfct_get_public_template( $file = 'frontend-template.php' ) {
	$theme_template = locate_template(
		array(
			'sifero-courier-timeline/' . $file,
		)
	);

	if ( $theme_template ) {
		return $theme_template;
	}

	$plugin_template = SFCT_PATH . 'templates/' . $file;

	return file_exists( $plugin_template ) ? $plugin_template : '';
}

The order is the contract, and the child comes first for a reason. locate_template() walks your names in the order you pass them, and for each name it checks get_stylesheet_directory() and then get_template_directory(). The child theme's directory is the stylesheet directory, so a child file shadows the same relative path in the parent. Reverse those two checks and child themes break on every site that has one.

Two things to check when you test it. locate_template() returns an absolute path or an empty string, so test with if ( $theme_template ) and not against a boolean you assumed. And the child only wins when it is genuinely the active stylesheet directory; a plugin cannot participate at all, which is why this pattern works where the plugin's own resolver cannot.

On the last question: CSS restyles the markup the plugin emits, and nothing more. A template override changes the markup, which is what you need when the difference is structural: a compact table instead of a card grid, a form that posts elsewhere, a timeline heading the theme's outline expects. The plugin gives up the guarantee that its own markup and classes are present, and it takes on documenting the variables a template may rely on, because a theme author will use them and you cannot un-ship that. WooCommerce accepts that cost, which is why wc_get_template() exists and why its docs warn that overriding a template means keeping it updated. Not offering overrides is also a choice; it is one the plugin does not state while its documentation implies the opposite.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

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