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:
- The child theme's
functions.php— version controlled, deploys with the site, survives plugin swaps. Best for agencies running a repeatable build kit. - A small site-specific plugin (
mu-pluginsworks nicely) — good when you don't control the theme. - 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:
addressCountryset to "UK" or "United Kingdom". UseGB. The other spellings get tolerated, butGBis the ISO code and it's what the validators expect.- Phone numbers with national formatting and no country code.
0113 496 0000is meaningless outside the UK.+44 113 496 0000is not. - Times with a.m./p.m. Use 24-hour
HH:MM.opensandclosesare strings, not times, but keep them machine-clean anyway. areaServedas a comma-separated string. It's an array of entities. Two towns means two objects.
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.