TechEarl

Build a Filterable CTA System in WordPress With Hooks

Render every call-to-action through one helper whose phone, label, URL, and visibility are all WordPress filters, so you change a CTA per context from a must-use plugin without touching the theme.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
How to build a filterable call-to-action system in WordPress: one te_cta() helper resolves phone, label, URL, and visibility through apply_filters so you override CTAs per context from a must-use plugin with no theme edits.

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:

php
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_visible runs first and short-circuits the rest. If a hook returns false, the function bails before doing any work, so a hidden CTA costs nothing. Default is true.
  • te_cta_phone is the source of truth for the number. Everything else (the tel: URL, the data-phone attribute) derives from whatever this filter returns, so an override here cascades correctly without you having to also override the URL.
  • te_cta_label is 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_url defaults to a tel: 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 raw tel: 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:

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":

php
<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:

php
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:

php
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:

php
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:

bash
# 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
<?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

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPHooksFiltersapply_filtersmu-plugins

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Using Claude CLI to Manage WordPress Sites

How I use Claude CLI to run WordPress and ACF work end-to-end: ACF field group generation, WP-CLI orchestration, log triage, plugin debugging, bulk content ops. Concrete prompts, what it gets wrong, and where it fits in an agency workflow.