Abdulkader Safi

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

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.

5 min read

Share
A JSON-LD graph in a page source showing linked organisation, website, page, breadcrumb and service nodes.

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.

Last updated 18 Aug 2026 · filed under Laravel, blade, filamentphp, plugin, tools, web application, seo

Every entry on this project

28 build notes, in order

Including the ones where nothing worked. You are on part 12.

Read the series →

FAQ

Frequently asked questions

Why do package routes sometimes take precedence over an application's own?

Because service providers boot before the framework loads the application route files, and routes are matched in registration order. A package that registers routes during its boot therefore sits in front of everything defined in the application, which is the opposite of what most packages document. It only shows up when a package registers a broad pattern such as a catch-all with one or two segments, and only when the application has a route that pattern can match. Registering from a booted callback instead, which runs after every provider, puts the package's routes genuinely last while keeping route caching intact.

Should a page be typed as Product, or should it point at a Product?

It should point at one. A page about a product is a web page whose subject is a product, so the correct shape is a WebPage node with a mainEntity reference to a separate Product node. Typing the page itself as a Product validates cleanly and is still wrong, which is why the mistake is so common. The distinction is between types that refine what the page is, such as AboutPage or ContactPage, and types that describe what the page is about, such as Product, Service, Event or Article. The first group replaces the page's type, the second group becomes its own node.

Should structured data be generated from content blocks or entered by hand?

Both, with generated content preferred and typed entry always available. Generating from a block is better when it applies, since the questions and answers already exist and cannot drift from what renders. But in an extensible system most blocks are written by whoever installed the package, and those blocks will not implement the hook, so a system that only generates leaves those sites with no schema and no way to add any short of editing PHP. Offering both with a clear precedence rule, typed entries replacing generated ones, covers both cases without producing duplicates.

What makes JSON-LD encoding a security concern rather than a formatting one?

The payload sits inside a script element, so any unescaped closing script tag in the data ends the block early and everything after it is parsed as markup written by whoever typed it. On a CMS that means a page title or a meta description is an injection vector. Encoding with the flags that hex-escape angle brackets, ampersands and quotes closes it, and it is worth a test that puts a closing script tag and an image tag into a text field and asserts the rendered document contains exactly one closing script tag. Keeping unicode unescaped at the same time matters for non-Latin content, which otherwise triples in size.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from Filament Atelier

All 28 entries →
  • A version number changing from 0.5.0 to 1.0.0 beside a list of deferred items.
    Filament Atelier

    · part 28 of 28

    Tagging 1.0.0 with four features missing, on purpose

    The gate list had thirteen items. Six went in, seven did not, and the tag went out anyway. What the number promises is that the API stops moving, not that the feature list is finished, and conflating those two is how packages sit at 0.x for three years.

  • The same block of JavaScript appearing in three different Blade layout files.
    Filament Atelier

    · part 26 of 28

    A script in three layouts is a contract nobody signed

    The editor's preview needed a few lines of JavaScript in the page it renders. I put them in the shipped layout, then copied them into two more. Anyone writing their own layout had to copy them too, and missing them broke half the editor with no error at all.