Skip to content

Structured data

Abdulkader Safi edited this page Aug 18, 2026 · 1 revision

Structured data

Every public page emits one <script type="application/ld+json"> holding a single @graph: who publishes the site, what the site is, what this page is, where it sits, and whatever the page is about.

Three sources feed it, and the split is the whole design:

  1. Site-wide facts a developer or client sets once, on Settings → Site details
  2. Per-page choices, under Structured data on the page settings screen
  3. Facts derived from blocks, which nobody types twice

Nothing is stored. The graph is built at render from data you already filled in, so there is no cache to clear and no second copy to keep in step.

What a page emits

A service page at a nested slug, with an FAQ block on it:

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "ProfessionalService",
      "@id": "https://example.com#organization",
      "name": "Atelier Demo",
      "url": "https://example.com",
      "telephone": "+971 4 000 0000",
      "address": { "@type": "PostalAddress", "addressLocality": "Dubai", "addressCountry": "AE" },
      "geo": { "@type": "GeoCoordinates", "latitude": 25.2048, "longitude": 55.2708 },
      "openingHoursSpecification": [
        { "@type": "OpeningHoursSpecification",
          "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday"],
          "opens": "09:00", "closes": "18:00" }
      ],
      "contactPoint": [
        { "@type": "ContactPoint", "contactType": "sales",
          "telephone": "+971 4 000 0000",
          "availableLanguage": ["Arabic", "English"] }
      ]
    },
    { "@type": "WebSite", "@id": "https://example.com#website",
      "publisher": { "@id": "https://example.com#organization" } },
    { "@type": "WebPage", "@id": "https://example.com/services/web-design#webpage",
      "inLanguage": "en",
      "isPartOf": { "@id": "https://example.com#website" },
      "mainEntity": { "@id": "https://example.com/services/web-design#mainentity" } },
    { "@type": "BreadcrumbList", "itemListElement": [ "…Home, Services, Web design…" ] },
    { "@type": "Service", "@id": "https://example.com/services/web-design#mainentity",
      "provider": { "@id": "https://example.com#organization" },
      "offers": { "@type": "Offer", "price": "5000", "priceCurrency": "AED" } },
    { "@type": "FAQPage", "mainEntity": [ "…questions from the FAQ block…" ] }
  ]
}

Nodes reference each other by @id rather than repeating the organisation on every one. The scheme is fixed and derived from the URL, never stored:

Node @id
Organization {site}#organization
WebSite {site}#website
Logo {site}#logo
This page {url}#webpage
What it is about {url}#mainentity
Breadcrumbs {url}#breadcrumb
FAQ {url}#faq
Listing {url}#itemlist
Share image {url}#primaryimage

Previews emit nothing. They are noindex anyway, so a graph there is noise.

Site details

Settings → Site details in the panel. Everything here feeds the Organization node on every page.

Field Becomes
Name, description name, description, translatable
Legal name legalName
Type The node's @type: Organization, or a LocalBusiness subtype
Logo An ImageObject referenced by @id
Profile URLs sameAs
Telephone, email telephone, email
Address, geo PostalAddress, GeoCoordinates
Price range, areas served priceRange, areaServed
Opening hours openingHoursSpecification
Contact points contactPoint
Founded, employees, VAT, tax foundingDate, numberOfEmployees, vatID, taxID

It is a screen rather than config because this is client-owned data that changes without a deploy. Tokens, locales and layouts are a developer's decisions and stay in a file; a phone number is not.

Picking a LocalBusiness type reveals the contact section, including opening hours. An Organization with no premises has no hours to give, and hours are the single most looked-at fact in a local result.

Opening hours are one row per set of hours, listing the days that share them: weekdays nine to six, Friday morning only. That is both the shape schema.org wants and the way a person thinks about it.

Contact points say who answers and in which language. A bare telephone says there is a number, which is not the part a search result can use. "Sales, answered in Arabic and English" is.

Page type

On the page settings screen, under Structured data. Page-level, not per locale: a page that is a Service in English is a Service in Arabic.

Choosing a type reveals the few fields it needs. None of them repeat a field the page already has, so the name comes from the meta title, the description from the meta description, the image from the share image, and the dates from publishing.

Type What it adds
Standard page Nothing. The correct default.
About, Contact, Listing Refines the WebPage type
Article Author, published date. Falls back to the organisation as author.
Service Service type, areas served, price, currency
Product SKU, brand, price, currency, availability, condition, price valid until
Event Start, end, location, price, status, attendance mode
Person Name, job title, profile URL
Job vacancy Posted, closes, type, remote, city, salary

The distinction that matters

Page-shaped types (about, contact, listing) refine the WebPage node itself, because an About page is a web page.

Thing-shaped types (article, service, product, event, person, vacancy) become their own node, linked from the WebPage through mainEntity, because a page about a product is not a product.

Marking a page as @type: Product outright is the most common structured data mistake, and validators do not catch it.

Defaults that do work for you

  • An Article with no author credits the organisation rather than nobody.
  • An Event defaults to going ahead and in person, so the common case needs no thought and cancelling is one select. A cancelled event with no eventStatus keeps advertising itself.
  • A vacancy dates itself from the publish date and locates itself from the site address. Marking one remote sets jobLocationType, without which a remote role is filtered out of remote searches, which is the whole reason somebody posts one.
  • A listing page lists the pages directly under it, derived from the slug path, so a services index stays right when a service is added. Direct children only, and a noindexed child is left out.

FAQ

Two ways in, and a real site uses both.

From a block

The shipped FAQ block turns its questions into FAQPage automatically. Nothing to configure and nothing to type twice; the repeater is already the right shape.

Two FAQ blocks on one page merge into a single FAQPage rather than emitting two, a question with no answer is dropped, and a hidden section contributes nothing.

Typed on the page

Under Structured data → FAQ, per locale. This is not a fallback: on a site whose blocks you wrote yourself, a custom FAQ section has no schema unless somebody remembered to add it, and nobody should edit a PHP class to get an FAQ into the head.

Typed entries win. Typing a question a block already provides replaces it rather than listing it twice.

⚠️ Google expects FAQ data to correspond to something a visitor can see on the page. Typed questions are for content that is on the page in another form, prose most often, not for questions that appear nowhere on it.

Breadcrumbs

A mode rather than a toggle:

Mode What it does
From the slug path The default. services/web-design gives Home › Services › Web design.
Typed here For a page whose slug is not its hierarchy. A step with no URL means this page.
None For a page that should claim no position at all.

Nested slugs are already a hierarchy, so the default needs no parent relationship and no configuration. A flat slug gets no breadcrumb: Home › Page is a trail nobody needed.

A block that describes itself

Any block can contribute nodes, built from data the client already typed into it:

use Safi\Atelier\Schema\StructuredData;

public static function structuredData(array $attributes, string $locale, string $url): array
{
    return [[
        '@type' => 'FAQPage',
        '@id' => StructuredData::id($url, 'faq'),
        'inLanguage' => $locale,
        'mainEntity' => collect($attributes['items'] ?? [])->map(fn (array $item) => [
            '@type' => 'Question',
            'name' => $item['question'],
            'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['answer']],
        ])->all(),
    ]];
}

Return a list of nodes. The attributes arrive collapsed to $locale with tokens resolved, exactly as your view receives them, so the schema cannot describe something different from what rendered. Nodes sharing an @id merge. A hidden section contributes nothing.

You never have to implement it. Anything a block does not describe can be typed on the page.

Pages Atelier does not own

A blog post or a services record lives on your route, in your view, and Atelier never sees it. What it should not have to reinvent is the organisation behind the site: an Article whose publisher is typed out by hand drifts from every other page the first time a phone number changes.

@include('atelier::partials.schema', ['nodes' => [[
    '@type' => 'Article',
    '@id' => url()->current().'#article',
    'headline' => $post->title,
    'datePublished' => $post->published_at?->toAtomString(),
    'author' => ['@type' => 'Person', 'name' => $post->author->name],
    'publisher' => ['@id' => \Safi\Atelier\Schema\StructuredData::siteId('organization')],
]]])

That emits the Organization and WebSite nodes plus whatever you pass, with the same pruning, merging and escaping an Atelier page gets. From PHP, StructuredData::for($nodes) returns the graph and ->toJson() the payload.

Pair it with ->sitemap([...]) from SEO and a blog is in the sitemap and in the graph without Atelier knowing it exists.

Empty values never appear

A client who fills in nothing still gets a valid graph rather than a node full of nulls. Anything blank is dropped, and a nested node left holding only its own @type, a Person with no name or an address with no address, goes with it. 0 and false stay: a price of zero is a fact.

The encoding is a security boundary

This lands inside a <script> block, so a client typing </script> into a meta title would otherwise close it and everything after becomes markup they wrote. Every angle bracket, ampersand and quote is hex-escaped, and Arabic stays readable rather than becoming \uXXXX escapes.

You do not have to do anything about this. It is worth knowing it is handled, because it is the reason not to assemble JSON-LD with string concatenation in your own views.

What is deliberately not emitted

  • Review and AggregateRating from testimonials. Google ignores reviews a business publishes about itself, and the testimonials block has no rating field. Only worth it attached to a Product or Service, with a rating added to the block.
  • HowTo. Its rich results were dropped in September 2023, so it is markup for nobody.
  • WebSite SearchAction. The sitelinks search box was deprecated in November 2024.

Checking it

curl -s https://example.com/services/web-design \
  | sed -n 's/.*ld+json">\(.*\)<\/script>.*/\1/p' \
  | python3 -m json.tool

Then run the live URL through both:

⚠️ Half of the schema advice online is out of date. FAQ rich results were restricted in August 2023 to well-known government and health sites, so the markup is still worth emitting for Bing and answer engines but will not put an accordion under a normal client's listing. Still earning rich results: Breadcrumb, Product with Offer, Event, Video, JobPosting, Course, Recipe, Q&A, and Article in a limited way.

Clone this wiki locally