-
Notifications
You must be signed in to change notification settings - Fork 0
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:
- Site-wide facts a developer or client sets once, on Settings → Site details
- Per-page choices, under Structured data on the page settings screen
- 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.
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.
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.
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 |
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.
- 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
eventStatuskeeps 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.
Two ways in, and a real site uses both.
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.
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.
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.
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.
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.
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.
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.
-
ReviewandAggregateRatingfrom 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. -
WebSiteSearchAction. The sitelinks search box was deprecated in November 2024.
curl -s https://example.com/services/web-design \
| sed -n 's/.*ld+json">\(.*\)<\/script>.*/\1/p' \
| python3 -m json.toolThen run the live URL through both:
- Rich Results Test, which tells you what Google will do with it
- Schema.org validator, which tells you whether it is correct, which is not the same question
Building
Running a site
Reference