The cleanest way to make a call-to-action that changes by context (a different phone number on every State page, a hidden CTA on one landing template, a campaign label on category archives) is to render it through a single helper whose every part is a filter. The theme calls te_cta( 'header' ) and never decides anything; all the deciding happens in a must-use plugin that hooks te_cta_phone, te_cta_label, te_cta_url, and te_cta_visible. Here is the helper:
const TE_CTA_DEFAULT_PHONE = '+1-800-555-0199';
function te_cta( $location = 'header', array $context = [] ) {
$context = array_merge( [
'location' => $location,
'queried_object' => get_queried_object(),
'is_tax' => is_tax(),
'is_singular' => is_singular(),
], $context );
$visible = apply_filters( 'te_cta_visible', true, $location, $context );
if ( ! $visible ) {
return;
}
$phone = apply_filters( 'te_cta_phone', TE_CTA_DEFAULT_PHONE, $location, $context );
$label = apply_filters( 'te_cta_label', 'Call now', $location, $context );
$url = apply_filters( 'te_cta_url', 'tel:' . preg_replace( '/[^0-9+]/', '', $phone ), $location, $context );
do_action( 'te_cta_before', $location, $context );
printf(
'<a class="te-cta te-cta--%1$s" href="%2$s" data-phone="%3$s">%4$s</a>',
esc_attr( $location ),
esc_url( $url, [ 'tel', 'http', 'https', 'mailto' ] ),
esc_attr( $phone ),
esc_html( $label )
);
do_action( 'te_cta_after', $location, $context );
}That is the whole spine. Every value the markup uses goes through a filter first, so the default behaviour ships in this one function and every deviation lives elsewhere as a hook. The theme stays dumb on purpose.
What each filterable part does
The helper resolves four things, in this order:
te_cta_visibleruns first and short-circuits the rest. If a hook returnsfalse, the function bails before doing any work, so a hidden CTA costs nothing. Default istrue.te_cta_phoneis the source of truth for the number. Everything else (thetel:URL, thedata-phoneattribute) derives from whatever this filter returns, so an override here cascades correctly without you having to also override the URL.te_cta_labelis the visible text. A header CTA might say "Call now"; a footer one might spell the number out. That is a per-location default, set by the caller, overridable by a filter.te_cta_urldefaults to atel:link built from the resolved phone. It is still a separate filter so you can point a CTA at a contact form or a tracking redirect when a rawtel:is not what you want.
The two do_action() calls (te_cta_before and te_cta_after) are escape hatches: they let a plugin inject markup around the link (a wrapper, an icon, an analytics pixel) without anyone editing the printf. Every dynamic value is escaped at output with esc_attr(), esc_html(), and esc_url(), and esc_url() is given an explicit protocol allowlist so a filter cannot smuggle in a javascript: URL.
The $context array is the important part for the override logic. It carries the location string plus the current queried object, is_tax(), and is_singular(), so a hook has everything it needs to decide without calling the conditional tags again. You can merge more into it at the call site when a particular template knows something the global query does not.
Calling it from the theme
The theme's only job is to pick a location and call the helper. In header.php:
<div class="site-header__cta">
<?php te_cta( 'header' ); ?>
</div>In footer.php, with a different default label so the footer spells the number out instead of saying "Call now":
<div class="site-footer__cta">
<?php
add_filter( 'te_cta_label', function ( $label, $location ) {
return 'footer' === $location ? 'Questions? Call us' : $label;
}, 10, 2 );
te_cta( 'footer' );
?>
</div>That is every theme touch you will ever make for CTAs. From here on, nothing in the theme changes. The header and footer each render a CTA, both default to the same number, and both are now fully controllable from outside the theme.
The payoff: override behaviour by context, no theme edits
This is the reason to build it this way. Drop a file into wp-content/mu-plugins/ and you can bend the CTA to any context WordPress can detect, without opening a single template. Must-use plugins load automatically and survive theme switches, so the logic is not tied to whatever theme happens to be active.
Change the phone on every page in the state taxonomy:
add_filter( 'te_cta_phone', function ( $phone, $location, $context ) {
return is_tax( 'state' ) ? '+1-800-555-0100' : $phone;
}, 10, 3 );Give one industry category its own number by reading the queried term out of the context the helper already assembled:
add_filter( 'te_cta_phone', function ( $phone, $location, $context ) {
$term = $context['queried_object'] ?? null;
if ( $term instanceof WP_Term && 'plumbing' === $term->slug ) {
return '+1-800-555-0142';
}
return $phone;
}, 10, 3 );Hide the footer CTA on a specific page template while leaving the header CTA alone:
add_filter( 'te_cta_visible', function ( $visible, $location ) {
if ( 'footer' === $location && is_page_template( 'landing.php' ) ) {
return false;
}
return $visible;
}, 10, 2 );None of those touched header.php, footer.php, or the helper. Each is a self-contained hook that reads the location and context and returns a value. Because te_cta_url derives from the resolved phone, the State-page override above also fixes the tel: link and the data-phone attribute for free; you change the number in one place and the markup stays consistent.
Why this scales where template conditionals do not
On a small site you could get away with an if ( is_tax( 'state' ) ) in the template. The pattern earns its keep on the kind of site where that breaks down: hundreds or thousands of numbers, one per state, per industry, per campaign, per franchise location.
Pile that into templates and you get a wall of conditionals smeared across header.php and footer.php, duplicated anywhere else a CTA appears, and every change is a theme edit and a redeploy. Worse, the same logic drifts out of sync between the two templates, so the header and footer start disagreeing about which number a page shows.
The filterable layer collapses all of that into one place. The decision logic lives in a single mu-plugin (or a few, grouped by concern: one for the state map, one for the campaign overrides), every CTA on the site reads from the same resolution path, and a change to a number is a data edit in the plugin, not a theme deploy. You can drive the te_cta_phone filter off an options table, an ACF field, or a CSV of state => number pairs without the theme knowing or caring where the value came from. The theme keeps calling te_cta( 'header' ) forever; the intelligence moves to where it belongs and stays editable independently of the design.
It also keeps every CTA consistent by construction. There is exactly one helper that builds the markup, one escaping pass, one place the tel: URL is derived. Add a rel attribute or an analytics hook once and every CTA on the site inherits it.
Verify it
Load the front page and a state archive, then confirm the number actually changes. With the State override active, the archive should show +1-800-555-0100 and the home page should show the default:
# Home page: default number
curl -s https://example.com/ | grep -o 'href="tel:[^"]*"'
# A State taxonomy page: overridden number
curl -s https://example.com/state/california/ | grep -o 'href="tel:[^"]*"'The first should print the default tel: link; the second should print tel:+18005550100 (the preg_replace strips the dashes when building the URL, so compare against the digits). To confirm the visibility override, curl the landing template and grep for te-cta--footer: it should be absent there and present on a normal page. If a number does not change, the usual cause is a page cache serving stale HTML (purge it) or a hook registered with the wrong argument count, so check the , 10, 3 ) on any te_cta_phone filter that reads $context.
A drop-in must-use plugin
Here is the whole thing as one file you can drop into wp-content/mu-plugins/te-cta.php. It defines the helper and one example override; add your own filters below it.
<?php
/**
* Plugin Name: TE CTA
* Plugin URI: https://techearl.com/wordpress-filterable-cta-hooks
* Description: A filterable call-to-action helper. Render CTAs with te_cta(), override phone, label, URL, and visibility per context via filters.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-cta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const TE_CTA_DEFAULT_PHONE = '+1-800-555-0199';
function te_cta( $location = 'header', array $context = [] ) {
$context = array_merge( [
'location' => $location,
'queried_object' => get_queried_object(),
'is_tax' => is_tax(),
'is_singular' => is_singular(),
], $context );
$visible = apply_filters( 'te_cta_visible', true, $location, $context );
if ( ! $visible ) {
return;
}
$phone = apply_filters( 'te_cta_phone', TE_CTA_DEFAULT_PHONE, $location, $context );
$label = apply_filters( 'te_cta_label', 'Call now', $location, $context );
$url = apply_filters( 'te_cta_url', 'tel:' . preg_replace( '/[^0-9+]/', '', $phone ), $location, $context );
do_action( 'te_cta_before', $location, $context );
printf(
'<a class="te-cta te-cta--%1$s" href="%2$s" data-phone="%3$s">%4$s</a>',
esc_attr( $location ),
esc_url( $url, [ 'tel', 'http', 'https', 'mailto' ] ),
esc_attr( $phone ),
esc_html( $label )
);
do_action( 'te_cta_after', $location, $context );
}
// Example: a dedicated number on every State taxonomy page.
add_filter( 'te_cta_phone', function ( $phone, $location, $context ) {
return is_tax( 'state' ) ? '+1-800-555-0100' : $phone;
}, 10, 3 );The pattern generalises past phone numbers. Anything that varies by context (a banner message, a booking link, a regional disclaimer) fits the same mould: render it through one helper, expose its parts as filters, move the decisions into a plugin. The theme should describe the page, not decide the business rules.
See also
- Autoload a must-use plugin with Composer: how to structure the mu-plugin that holds your CTA override logic so it loads cleanly and stays testable
- Write a custom WP-CLI command: pair the filter layer with a CLI command to bulk-import a state-to-number map, instead of hand-editing the plugin
- Clean up wp_head in WordPress: the same "decide once in a plugin, keep the theme dumb" instinct, applied to the default head output
- How to optimize WooCommerce: where a CTA filter reading product or category data needs care, because the slow path on a store is almost always the database
- Tracking down ACF performance issues: if you drive the CTA off ACF fields, this is the query bloat to watch for before it bites
Sources
Authoritative references this article was fact-checked against.
- apply_filters(): WordPress Developer Referencedeveloper.wordpress.org
- do_action(): WordPress Developer Referencedeveloper.wordpress.org
- Hooks: WordPress Plugin Handbookdeveloper.wordpress.org





