Skip to content
Abdulkader Safi

Filament Atelier

Building a demo blog route found a bug older than the feature

18 August 2026 Updated 18 August 2026 4 min read
A JSON-LD graph in a page source showing linked organisation, website, page, breadcrumb and service nodes.

v0.3.0 adds structured data: a JSON-LD graph on every page from three sources. While proving that a host app's own blog route could share it, the route 404d. Atelier's catch-all had been registered ahead of every route the application defines, since the first release, and the docs promised the opposite.

v0.3.0 is structured data. Every page now emits a JSON-LD graph describing the site, the page and what the page is about.

It also contains a routing fix that matters to people who do not care about schema at all, so that goes first.

The bug

Part of this release lets a host app's own pages share the site's graph. To prove it worked I added a /blog/{slug} route to the example app, pointed it at a view, and requested it.

The route was registered. route:list showed it. And Atelier's catch-all was matching first:

blog registered at position: 25
catch-all at position: 23

Package service providers boot before Laravel loads routes/web.php, and Laravel matches routes in registration order. So /{locale}/{slug?} sat in front of everything an application defined, and a client's own /blog/{slug} lost to it.

Three places in this project said the opposite. The routes file: "registered last and matched loosely, so this never shadows an app's own routes". The install guide: "your app's own routes are matched first, so nothing you already have breaks". And the example app's own comment.

It was wrong everywhere it was written down, which is worse than being undocumented, and it had been there since the first release.

It survived because the example app has no routes of its own. The welcome route was deleted early so the CMS could own the home page, so nothing ever competed.

The fix is registering from a booted() callback, which runs after every provider, so the catch-all really is last. Route caching still works, which is the thing that usually breaks when route registration moves.

Three sources for one graph

The schema itself splits three ways, and the split is the design.

Site-wide facts live on a new Site details screen: the organisation, its logo, social profiles, address, opening hours, contact points. It is a screen rather than config because this is client-owned data that changes without a deploy. Tokens and locales are a developer's decisions and belong in a file; a phone number does not.

Per-page choices are a type select: standard page, about, contact, listing, article, service, product, event, person, job vacancy. Choosing one reveals the few fields it needs, and none of them repeat something the page already has. The name comes from the meta title, the description from the meta description, the dates from publishing.

Facts derived from blocks. An FAQ block already holds questions and answers, so its schema is a transform of data that exists. Every other CMS asks the client to type it twice.

The modelling decision most sites get wrong

A page-shaped type refines the WebPage node itself, because an About page is a web page.

A thing-shaped type gets its own node, linked from the page through mainEntity, because a page about a product is not a product.

Marking a page as @type: Product outright validates fine and is wrong. Validators do not catch it, which is why it is so common.

Typed schema is not a fallback

I built the FAQ block generation first, which felt like the elegant answer: no double entry, the data is already right.

Then the obvious objection, which came from actually thinking about who uses this: most blocks on a real site are written by whoever installed the package. A custom FAQ section has no schema unless somebody remembered to add the method. Nobody should edit a PHP class to get an FAQ into the head.

So FAQ questions and breadcrumb trails are editable directly, per locale, under Structured data, on a page built from anything at all. Typed entries win over derived ones, so typing a question a block already provides replaces it rather than listing it twice.

There is a rule attached: Google expects FAQ data to match something a visitor can see. Typed questions are for content on the page in another form, prose most often, not for questions that appear nowhere.

The precedence rule broke my first attempt

Making typed win looked like one line: skip a block contribution when its node id is already in the graph.

That also blocked the second FAQ block on a page from merging into the first, because it found the id already there. A test caught it. It now tracks which ids came from the settings screen specifically, rather than asking the graph what it already has.

Encoding as 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. There is a test that types </script><img src=x onerror=alert(1)> into a meta title and asserts the document contains exactly one closing script tag. Arabic stays readable rather than becoming escape sequences, which matters when half the content is Arabic.

Two things I decided not to build

Review from the testimonials block. Google ignores reviews a business publishes about itself, and the block has no rating field to aggregate. Emitting it would be markup that is at best ignored.

HowTo. Its rich results were dropped in September 2023, so it is markup for nobody.

Both are listed in the docs with the reason, because "why is there no Review markup" is a fair question and better answered before it is asked.

Where it stands

v0.3.0. A graph on every page, a settings screen, nine page types, FAQ from blocks or typed, breadcrumbs derived from the slug path, and a partial so a host app's own routes share the same organisation node rather than a copy that drifts.

122 tests. One thing still unverified: neither Google's Rich Results Test nor the Schema.org validator has seen the output, because both need a public URL. The tests cover the graph's shape, not anybody's opinion of it.