safi/filament-atelier
A visual page builder plugin for Filament. Developers define section blocks in PHP with a Blade view and a settings schema; clients assemble pages in a full-screen editor with a live, responsive iframe preview. Pages are stored as JSON and rendered server-side by Blade. Multi-language.
A bug fix. No migration, no config change, no new table. composer update safi/filament-atelier is the whole upgrade.
Removing an image in the page editor did not save the removal. The field cleared, but
the block tree kept the old path, so the preview still showed the image and publishing put
it back on the live page. Filament removes a file through a renderless method that writes
component state directly, so the editor's updatedData() hook never ran and the cleared
value never reached the tree. The upload field now hooks the removal itself.
If a page already has an image you thought you deleted, open the section, remove it again and it will stick this time.
Removed and replaced images were never deleted from the disk. Filament only deletes a
stored file when deleteUploadedFileUsing is set, and it was not, so every image you
removed or replaced stayed on the disk with nothing referencing it. New removals now delete
the file. Files orphaned before this release are still there and need clearing by hand.
Structured data. Every page now emits a JSON-LD graph describing the site, the page and what the page is about, from three sources: a settings screen, a page type select, and blocks that describe themselves.
It also fixes a routing bug that has been there since the first release and is worth reading even if none of the rest interests you.
This release adds a table and a column. After updating:
composer update safi/filament-atelier
php artisan vendor:publish --tag=filament-atelier-migrations
php artisan migrate
Nothing else is required. A site that fills in none of the new screens still emits a valid graph, because everything derivable is derived.
Atelier's catch-all was shadowing the host app's own routes. The package registered its
routes as its provider booted, which happens before Laravel loads routes/web.php, and
Laravel matches in registration order. So an app's own /blog/{slug} lost to Atelier's
/{locale}/{slug?} and 404'd, which is the opposite of what this package documents and
promises. Routes now register from a booted() callback, after every provider, so the
catch-all really is last. Route caching still works.
If you worked around this by moving routes into a service provider, renaming a path, or putting your blog on a third segment, you can undo it after upgrading.
A JSON-LD graph on every public page. One <script type="application/ld+json"> holding
the Organization (or the LocalBusiness subtype you pick), the WebSite, this WebPage
with its language and dates, a BreadcrumbList derived from nested slugs, ImageObject
nodes for the logo and share image, and whatever the page is about.
Nodes reference each other by [@id](https://github.com/id) rather than repeating the organisation on each one.
Nothing is stored: the graph is built at render from data you already filled in, so there
is no second copy to keep in step and no cache to clear. Empty values never appear, so a
page with nothing filled in is still valid rather than a node full of nulls. The Arabic
page describes the same organisation in Arabic, pointing at the same [@id](https://github.com/id). Previews emit
nothing, being noindex anyway.
The encoding is a security boundary rather than formatting: a client typing </script>
into a meta title cannot close the block, and Arabic stays readable rather than becoming
\uXXXX escapes.
A Site details screen, under a Settings group in the panel. The organisation behind the site: name and description (translatable), legal name, logo, type, social profiles, and for a business with premises its address, geo, price range, areas served, opening hours and contact points, plus founding date, employee count, VAT and tax numbers.
It is a screen rather than config on purpose. Tokens, locales and layouts are a developer's decisions and belong in a file. An address is client-owned data that changes without a deploy, and the person who knows it does not have a text editor open.
Hours are one row per set, listing the days that share them, which is both what schema.org wants and how a person thinks about them. A contact point says who answers and in which language, which a bare telephone number does not.
A page type select, on the page settings screen under Structured data: standard page, about, contact, listing, article, service, product, event, person or job vacancy. Choosing one reveals the few fields it needs and nothing else, and none of them repeat a field the page already has, since the name and description come from the meta fields, the image from the share image and the dates from publishing.
Page-shaped types refine the WebPage node itself, because an About page is a web page.
Thing-shaped types become their own node linked through mainEntity, because a page about
a product is not a product. The type is page-level, not per locale: a page that is a
Service in English is a Service in Arabic.
Defaults do the work where they can. An Article with no author credits the organisation. An
Event defaults to going ahead and in person, so cancelling is one select and a cancelled
event stops advertising itself. A vacancy dates itself from the publish date and locates
itself from the site address, and marking it remote sets jobLocationType, without which a
remote role is filtered out of remote searches. A listing page emits an ItemList of the
pages directly under it, derived from the slug path, so a services index stays right when a
service is added.
FAQ schema, two ways. The FAQ block turns its questions into FAQPage automatically,
with nothing typed twice: two FAQ blocks on one page merge into a single node, a question
with no answer is dropped, and a hidden section contributes nothing.
Questions can also be typed under Structured data, per locale, on a page built from anything at all. That 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 over derived ones. Note that Google expects FAQ data to correspond to something a visitor can see, so this is for content on the page in another form, not for questions that appear nowhere.
Breadcrumbs take a mode in the same place: from the slug path (the default), typed by hand for a page whose slug is not its hierarchy, or off.
Blocks can describe themselves. structuredData($attributes, $locale, $url) on a block
returns nodes for the page's graph, and a node whose [@id](https://github.com/id) is already there merges into it.
The attributes arrive collapsed to the locale with tokens resolved, exactly as the view
receives them, so the schema cannot describe something different from what rendered. Adding
a block that contributes schema needs no change inside the plugin.
Your own routes can share the graph. A blog post or a services record lives on your route, in your view, and Atelier never sees it, but it should not have to reinvent the organisation:
[@include](https://github.com/include)('atelier::partials.schema', ['nodes' => [[
'[@type](https://github.com/type)' => 'Article',
'[@id](https://github.com/id)' => url()->current().'#article',
'headline' => $post->title,
'publisher' => ['[@id](https://github.com/id)' => \Safi\Atelier\Schema\StructuredData::siteId('organization')],
]]])
It emits the Organization and WebSite nodes plus whatever you pass, with the same
pruning, merging and escaping. The publisher is then the same node the rest of the site
points at rather than a copy that drifts the first time a phone number changes. Pair it with
->sitemap([...]) and a blog is in the sitemap and in the graph without Atelier knowing it
exists.
Review and AggregateRating from testimonials. Google ignores reviews a business
publishes about itself, and the block has no rating field to aggregate.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.Multiple layouts, picked per page. A minor rather than a patch because it adds a real
capability and a new registration method, not because anything breaks: no migration, and
every existing page keeps rendering through config('atelier.layout') untouched.
Multiple layouts, chosen per page. A site is rarely one shell: marketing pages want a navbar and footer, documentation wants a sidebar, a landing page often wants neither. The blocks are the same either way, so the shell is now a per-page choice. Register them where you register blocks:
AtelierPlugin::make()
->blocks(DefaultBlocks::all())
->layouts([
'site' => ['label' => 'Navbar and footer', 'view' => 'layouts.site'],
'docs' => ['label' => 'Sidebar', 'view' => 'layouts.docs'],
]);
A Layout select appears on the page settings screen, hidden entirely when no layouts
are registered. A layout is a key, a label and a view rather than a class, because a class
holding three strings is ceremony. The choice is page-level, not per locale: a layout is
structure, and both locales share one structure by design. The layout column already
existed, so there is no migration.
An unset layout falls back to config('atelier.layout'), and so does a key nobody
registered, because a page keeps its key after a developer removes that layout and every
public page 500ing is a bad way to find out.
$page to the layout, so a custom layout reading it worked on
the live site and 500'd in the preview. Found by the test asserting a preview renders
through the same layout as the public page.Makes the sitemap readable in a browser. No migration, no API change.
/sitemap.xml in a browser used to show a wall of
unlabelled URLs and timestamps, which reads as broken. It now renders as a table of URLs,
dates and locale alternates. Crawlers ignore the instruction that points at it, so
nothing about the machine-readable side changes.The SEO release: a sitemap, robots.txt, per-page noindex, and 301s when a slug changes.
This release adds a table. Run
php artisan vendor:publish --tag=filament-atelier-migrations and php artisan migrate
after updating, or renaming a slug fails on a missing atelier_page_redirects.
/sitemap.xml, both locales, with xhtml:link alternates per entry and
lastmod from published_at, which the table already stored and nothing read. Drafts and
noindexed pages are excluded. Written rather than pulled in: spatie/laravel-sitemap
exists to crawl a site and discover URLs, and Atelier already knows every URL it has.AtelierPlugin::make()->sitemap([...]) takes closures or invokable class
names, the same shape as ->blocks(). Each returns URL strings, or arrays with lastmod
and per-locale alternates. Sources run at request time, entries dedupe on loc, and a
source that throws fails the sitemap rather than silently shrinking it.robots.txt pointing at the sitemap and disallowing the preview route and the panel.
Note that Laravel ships a real public/robots.txt, and the web server answers that before
any route runs, so delete it or copy the Sitemap: line into it.noindex and nofollow toggles. One switch keeps a page out of
search results and out of the sitemap. The tag is emitted only when it says something,
since index, follow is what every crawler assumes anyway.vendor:publish --tag=filament-atelier-migrations and migrate.The head no longer disappears when an app supplies its own layout, and the README stops promising features that do not exist. No migration.
If you point atelier.layout at your own Blade view, this release needs two lines from
you. Nothing breaks without them, which is the problem: add them or your pages keep
rendering with no title, description, canonical, hreflang or Open Graph tags.
<head>
[@include](https://github.com/include)('atelier::partials.meta')
[@vite](https://github.com/vite)(['resources/css/app.css'])
{{-- After your stylesheet, so the tokens win. --}}
[@include](https://github.com/include)('atelier::partials.tokens')
</head>
Apps on the stock layout need no change.
atelier::partials.meta and atelier::partials.tokens. A host app that points
atelier.layout at its own Blade view keeps the full head with two [@include](https://github.com/include) lines,
documented in the README, Docs/installation.md and the
wiki. Tokens are
a second include rather than part of the first, because they have to sit after the host
app's stylesheet while the meta does not care where it goes.atelier::layouts.site, so any app with its own
shell rendered pages with none of them, and previews stopped being noindex. Three
tests, one of which asserts the stock layout and a host app's layout emit the same head.->visibility('public'). It worked on a local
disk and 403'd on S3, so the tag pointed at an image no crawler could read.^5.0. It now lists what ships and,
separately, what does not.Docs/ is export-ignored, so nobody installing the package could read
it, and it was never written for using Atelier: it is the PRD, the task breakdown and the
research behind the decisions.Docs/tasks/08-animation.md is
marked dropped and keeps its Livewire guidance, which is still right for a block author.Repository hygiene, not package code. Nothing in src/ changed, so there is no migration
and no upgrade step beyond composer update.
SECURITY.md, and GitHub private vulnerability reporting enabled on the repository, so
a researcher has somewhere to send a report that is not a public issue.^8.3 floor honest: the suite cannot run
there because Pest requires 8.4, so it checks the two halves the constraint actually
promises, that the package parses on 8.3 and that a consumer on 8.3 can install it.
Third-party actions are pinned to full commit SHAs rather than tags, because a tag can be
re-pointed at malicious code by whoever controls the action..github/dependabot.yml, covering the workflows with a seven-day cooldown, so an update
is never merged the moment it is published.withoutVite() lets the suite run on a clean checkout instead of
failing on a missing Vite manifest.Design tokens, shared section controls and page revisions. Three of the four gaps that were holding up other features, plus one routing bug that served the wrong page.
This release adds a table. Run
php artisan vendor:publish --tag=filament-atelier-migrations and php artisan migrate
after updating, or atelier_page_revisions is missing and publishing a page fails.
Safi\Atelier\Tokens and atelier.tokens overrides them key by key,
which means an existing install picks them up without republishing its config. A block
attribute stored as {"token": "color.primary"} is resolved to
var(--atelier-color-primary) before the view runs.[dir="rtl"] rule rather than by locale code.supports() and gets those controls in its
settings pane, built once rather than reimplemented per block. Background and vertical
space ship now. Every control emits an inline style built from tokens, never a utility
class, because a class written in PHP is a class the host app's Tailwind never scans.
Leaving a control unset keeps the block's own styling.atelier_page_revisions with who published it, pruned to atelier.revisions.keep
(20 by default). Page::restoreRevision() copies one back into the draft, deliberately
not into the live page, so restoring is an undo the editor reviews and publishes.
No panel UI yet. The table ships as its own migration rather than as a change to one
that already ran, so it needs the publish and migrate above.Page::unpublish() existed and nothing
called it, so taking a page down meant editing the database.Docs/tasks/11-seo-v0.2.md, the SEO work planned for v0.2.0: sitemap, robots.txt,
JSON-LD, per-page indexing control, slug redirects, and the head markup fixes.$shared attribute bag from the renderer and merge their own classes
into it. A custom block's root element should now be
<section {{ $shared->class([...]) }}> rather than writing data-atelier-block itself.
Existing custom blocks keep working; they just do not get the shared controls.Docs/tasks/ audited against the code and marked with what is actually
built. Docs/tasks/README.md carries the status of each feature and the gaps that block
more than their own feature.Docs/installation.md and the wiki, because a
missed vendor:publish fails late rather than loudly.services/web-design was unreachable. The public route read the
first path segment as a locale and discarded the rest, so the URL returned 200 with the
services page rendered instead of the one asked for. A slug is now the whole path
after the locale.atelier::layouts.site. An app that points
config('atelier.layout') at its own view gets no title, description, canonical,
hreflang or Open Graph tags.visibility('public'), so it works on a local
disk and breaks on S3.table atelier_pages has no column named slugs. The settings form edits slugs as slugs.{locale}, which arrives as a top-level
slugs key; the edit screen stripped it before saving and the create action did not.
Both paths now share one trait, and creating a page with no slug typed generates one
from the title rather than leaving the page unreachable.First release. Installable from Packagist as safi/filament-atelier.
A page is a JSON tree of typed blocks rendered by Blade at request time. The editor preview and the public page run through the same views and the same stylesheet, which is the reason the plugin exists.
The editor
Blocks
type(), label(),
icon(), category(), schema(), supports(), translatable() and view().
Registering one takes a line in the panel provider and no change inside the plugin.BlockRegistry resolves types for the renderer, the picker and the settings pane.Pages and SEO
/{slug} and /{locale}/{slug}, registered last so an app's own
routes still win. Route caching works.Bilingual
dir="rtl" and lang="ar", and the blocks use no physical
direction utilities.Draft and publish
draft_content and published_content as separate columns. Editing writes the draft,
publishing copies it across, and the public route reads published only. A draft 404s.noindex by meta tag and header, and never
cached.Packaging
FilamentAsset, so a consumer needs no build step for the panel.FileUpload never moved the temporary file: the
field said the upload was complete, the tree stored [], and the page showed no image.packages/filament-atelier/ to the repository root. Composer and
Packagist read composer.json from the root, and nothing could install it from a
subdirectory.How can I help you explore Laravel packages today?