Unlock Elementor Pro All articles
Agency Operations

The Postcode-Perfect Knowledge Panel: Wiring UK Local-Business JSON-LD Into Elementor Pro

Unlock Elementor Pro
The Postcode-Perfect Knowledge Panel: Wiring UK Local-Business JSON-LD Into Elementor Pro

Every UK agency roster seems to have one: the site with four SEO plugins, two of which both output LocalBusiness markup, one of which is a year out of date on the phone number. Google picks whichever it fancies. Nobody notices until the client rings up because the Knowledge Panel still shows the branch they closed in 2023.

Hand-writing the JSON-LD is more boring than clicking a wizard. It's also about eight lines of PHP, it survives plugin churn, and you can hand it over with a comment block explaining what each field does. That's the trade we're making here.

Why bother when an SEO plugin already does it

Because you don't control what the plugin outputs, when it outputs it, or whether the next freelancer updates the licence. On a typical Elementor Pro build the schema you care about is small: who the business is, where it trades, when it's open, and which patches of the UK it serves. That's a fixed shape. There's no reason for it to be generated by something you have to keep renewed.

The other reason is contradictions. If a plugin is emitting Organization on every page and you're also injecting it through an Elementor Custom Code block, you've got two entities describing one business, with different phone numbers. That's worse than no markup at all. One source of truth, in code, in version control.

Where the block should actually live

Three sensible homes, in order of preference:

  1. The child theme's functions.php — version controlled, deploys with the site, survives plugin swaps. Best for agencies running a repeatable build kit.
  2. A small site-specific plugin (mu-plugins works nicely) — good when you don't control the theme.
  3. Elementor Pro's Custom Code feature — perfectly valid, decent for a one-off where the client has no staging and no Git, but it lives in the database, so it won't follow you through a migration unless you export it deliberately.

Avoid the HTML widget. It only prints where you drop it, it gets copied when someone duplicates a page, and it's invisible to anyone doing a code review.

The pattern: one graph, one hook

The trick is a single @graph containing an Organization node and one LocalBusiness node per location, cross-linked by @id. Set a realistic business type rather than generic LocalBusiness — Plumber, Dentist, Electrician, ProfessionalService. Google reads the subtype and so do the other engines.

add_action( 'wp_head', function () {
    $schema = [
        '@context' => 'https://[schema.org](https://en.wikipedia.org/wiki/Schema.org)',
        '@graph'   => [
            [
                '@type' => 'Organization',
                '@id'   => home_url( '/#organisation' ),
                'name'  => 'Hartley & Sons Plumbing',
                'url'   => home_url( '/' ),
                'logo'  => home_url( '/wp-content/uploads/logo.png' ),
            ],
            [
                '@type'              => 'Plumber',
                '@id'                => home_url( '/#leeds' ),
                'parentOrganization' => [ '@id' => home_url( '/#organisation' ) ],
                'address'            => [
                    '@type'           => 'PostalAddress',
                    'streetAddress'   => '14 Kirkgate',
                    'addressLocality' => 'Leeds',
                    'addressRegion'   => 'West Yorkshire',
                    'postalCode'      => 'LS1 6BY',
                    'addressCountry'  => 'GB',
                ],
                'telephone' => '+44 113 496 0000',
                'areaServed' => [
                    [ '@type' => 'City', 'name' => 'Leeds' ],
                    [ '@type' => 'City', 'name' => 'Bradford' ],
                ],
                'openingHoursSpecification' => [
                    [
                        '@type'     => 'OpeningHoursSpecification',
                        'dayOfWeek' => [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday' ],
                        'opens'     => '08:00',
                        'closes'    => '17:30',
                    ],
                    [
                        '@type'     => 'OpeningHoursSpecification',
                        'dayOfWeek' => 'Saturday',
                        'opens'     => '09:00',
                        'closes'    => '13:00',
                    ],
                ],
            ],
        ],
    ];

    echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES ) . '</script>';
}, 20 );

Four things UK builders get wrong here, in order of how often I see them:

Multiple locations without copy-paste chaos

For a client with three branches, keep the Organization node once and add one node per location, each with its own @id anchored to a location page. If those pages are Elementor Theme Builder single templates driven by a custom post type or a repeater field, generate the nodes from the data rather than hard-coding them — otherwise the next branch opening means a code deploy.

If you are hard-coding, leave a clearly commented array at the top of the function listing the branches, so the handover note can say "add a line here for the Bristol office". Small kindness, saves an hour.

Where the Theme Builder bites

Theme Builder templates don't break wp_head output — they're PHP-rendered like anything else. What breaks is duplication. Two scenarios:

Custom Code on a template. Elementor Pro's Custom Code can be attached to individual pages, and if someone has attached an Organization snippet to the homepage template and the child theme is printing one, you get two. Grep the exported kit before you hand over.

Location pages built as normal pages. If the branch pages are Elementor pages rather than CPT singles, a hard-coded wp_head block will output the head-office address on all of them. Condition it: if ( is_page( 'leeds' ) ) or, better, read the address from the page's own custom fields.

Also worth a thought for consent scripts: if your CMP rewrites the page after load, it won't touch <head> JSON-LD, so that's one thing you can stop worrying about.

Validate, then validate again after go-live

Run the live URL through Google's Rich Results Test and the Schema Markup Validator. They disagree often enough that both are worth a minute. Rich Results will warn about missing priceRange or image on a LocalBusiness — not fatal, but cheap to add.

Then check the thing nobody checks: view source on the staging site. If staging is behind basic auth, the crawler can't see it, so validate on production within the first hour of DNS settling. I've seen a caching plugin strip <script> tags from <head> on one host and not another. It happens.

Handover: write the thing down

In the handover pack, one paragraph: "Business details live in inc/schema.php in the child theme. Phone, address and hours are at the top. If anything changes, edit there and validate at validator.schema.org. Don't install an SEO plugin that outputs LocalBusiness schema — it will conflict."

That's the whole discipline. No licence, no wizard, no surprises when the plugin list gets a spring clean in eighteen months' time.

All Articles

Related Articles

While the Office Is Shut: A Holiday Hours Plan for Elementor Pro Sites

While the Office Is Shut: A Holiday Hours Plan for Elementor Pro Sites

From Brand PDF to Living Styles: Wiring a UK Client's Guidelines into Elementor Pro

From Brand PDF to Living Styles: Wiring a UK Client's Guidelines into Elementor Pro

One Theme Builder, Two Tongues: A Welsh-English Build That Doesn't Fork the Site

One Theme Builder, Two Tongues: A Welsh-English Build That Doesn't Fork the Site