Abdulkader Safi

One shell per site was a decision I never made on purpose

Marketing pages want a navbar and a footer, documentation wants a sidebar, a landing page often wants neither. The blocks are identical in all three. v0.2.0 makes the shell a per-page choice, and the test for it caught a bug where a custom layout worked live and crashed in the preview.

4 min read

Share
The same page rendered twice, once in a marketing layout with a navbar and once in a documentation layout with a sidebar.

v0.2.0 lets a page pick its own layout. It is the first minor bump since the first release, because it adds a capability and a registration method rather than fixing something.

The gap

There was one layout, set in config, used by every page. That was never a decision, it was just where things ended up: a single atelier.layout key pointing at one Blade view.

Real sites are not one shell. Marketing pages want a navbar and a footer. Documentation wants a sidebar with a page list. A landing page often wants neither, because a nav bar is an exit and a landing page is trying not to have one.

The blocks are the same in all three cases. So the shell is a property of the page, not a different set of blocks.

A map, not a class

Blocks are classes in this project, and there was an obvious pull toward making layouts classes too. I did not.

A block earns a class because it carries a Filament schema, an icon, a category, translatable keys and a view. A layout carries a key, a label and a view name. A class holding three strings is ceremony.

AtelierPlugin::make()
    ->blocks(DefaultBlocks::all())
    ->layouts([
        'site' => ['label' => 'Navbar and footer', 'view' => 'layouts.site'],
        'docs' => ['label' => 'Sidebar', 'view' => 'layouts.docs'],
        'bare' => 'layouts.bare',
    ]);

The short form is just a view name and the label comes from the key. Registration sits next to ->blocks() so there is one idiom rather than two.

Three decisions inside a small feature

The select hides itself when no layouts are registered. A dropdown with one option is a question with one answer. Sites that never register a layout see no new control at all.

The choice is page-level, not per locale. A page that is a Service in English is a Service in Arabic, and the same logic applies to its shell: a layout is structure, and this project's whole model is that both locales share one structure. Putting it inside the locale tabs would let English get a sidebar and Arabic not, which is a bug you could ship without noticing.

An unknown key falls back rather than throwing. Delete a layout from the panel provider and pages still naming it keep rendering on the site-wide default. A page keeps its layout key after the code that defined it is gone, and every public page returning a 500 is a bad way to discover that.

The bug the test found

There is one test I wrote because the project has a rule: the preview and the public page must render through the same path. It asserts a preview renders through the same layout the public page will use.

It failed immediately, and not for the reason I expected.

PreviewController never passed $page to the layout. The public controller did. So a custom layout reading $page, for something as ordinary as highlighting the current item in a sidebar, worked perfectly on the live site and returned a 500 in the editor.

That is exactly the class of bug the shared render path rule exists to prevent, and it had been sitting there since the preview was built. It only surfaced because writing a second layout gave a reason to read $page in one.

Features find bugs that tests written for the feature would not, because the feature makes you use the code differently.

The failure modes I documented

Writing the guide for this forced me to list what breaks quietly when somebody writes their own layout:

  • Leave out atelier::partials.meta and the page has no title, description, canonical, hreflang or Open Graph tags, and previews stop being noindex.
  • Leave out atelier::partials.tokens and every design token resolves to nothing, so the section controls silently do nothing and Arabic loses its font stack.
  • Leave data-atelier-canvas off the element wrapping the blocks and the editor cannot swap the canvas on refresh.
  • Read $page without guarding it and anything rendering that layout outside the controllers breaks.

All four fail without an error. That list is now the middle of the layouts documentation rather than something a person discovers one at a time.

Where it stands

v0.2.0. Multiple layouts, picked per page, with the preview rendering through whichever one the page will actually use.

No migration: the layout column had existed since the first schema and nothing had ever read it. Which is its own small lesson about writing columns before writing the feature that needs them.

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

Every entry on this project

28 build notes, in order

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

Read the series →

FAQ

Frequently asked questions

When should a plugin feature be a class and when is a config map enough?

Ask what the type carries. A block in a page builder carries a form schema, an icon, a category, a list of translatable fields and a view, so a class gives it somewhere to live and something to inherit from. A layout carries a key, a label and a view name, and a class holding three strings adds a file, an import and a registration ceremony without adding capability. A keyed array with an optional long form covers it, and the short form where the value is just the view name keeps the common case to one line. If the type later grows behaviour, converting a map to a class is a contained change.

Should a page layout be chosen per page or per locale?

Per page, if the system stores one content structure shared across locales. A layout is structure, and letting each locale pick a different one contradicts the model in a way that produces subtly different sites per language, which nobody notices until a client does. It also multiplies the states to test. The same reasoning applies to anything structural, such as a page's schema type: a page that is a service page in one language is a service page in all of them. Per locale belongs to text, slugs and metadata, which genuinely differ.

What should happen when a page names a layout that no longer exists?

Fall back to the default and keep rendering. The situation arises normally, not exceptionally: a developer removes a layout from the registration list while pages in the database still reference it by key. Throwing means every one of those pages returns a 500 in production, which is a hostile way to communicate a configuration change and often happens after a deploy rather than during development. Falling back keeps the site up and makes the problem visible as pages looking wrong rather than being down, and the same reasoning applies to any stored reference to something registered in code.

Why can a custom layout work on the live site and crash in an editor preview?

Because the two are rendered by different controllers, and one may pass fewer variables to the view than the other. In this case the preview controller never passed the page model, so any layout reading it for something like a navigation highlight worked publicly and threw in the editor. The general fix is to pass the same variable set from every path that renders the layout, and to hold that with a test asserting a preview and a public render go through the same layout with the same data. It is also worth guarding optional variables in a layout, since it may be rendered by something other than the paths you wrote.

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.