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.
The version number, and the two editor changes that were worth waiting for.
composer update safi/filament-atelier
php artisan filament:assets # re-publishes the panel stylesheet
No migration, no config change. If you wrote your own layout, delete the editor script from it: the preview injects it now, and a copy in the layout only runs twice.
The public API is named in the README and will not move inside 1.x: Block, PageType, AtelierPlugin's registration methods, the documented methods on Page, Media, Tokens, Url, MenuSource, the two head partials, the data-atelier-canvas contract, every config key, and the table shapes. The registries, the Filament resources, the renderer and the controllers are internal and may change in a minor release.
It does not mean feature complete. "Not built yet" in the README is still accurate and still honest: no raw HTML block, no contact form block, no revisions UI, and the menu manager still ships behind a flag.
A Pages panel in the editor. A third button in the icon rail listing every page grouped by type, with a search box and the current one marked. Clicking one opens it in the builder, with the address bar following, so refresh and the back button behave. Editing a header on one page and a footer on another stops being ten clicks each way.
Links in the preview open the page they point at. Clicking a nav item in the preview used to navigate the iframe to that page's public URL, leaving the canvas showing one page while the section list described another, silently. Now it switches the builder to that page. A link Atelier does not own opens in a new tab and the builder stays where it was.
Drag to reorder sections. Same gesture and same library as the menu manager, with a handle on the section icon so a click still selects. Moving a section from the bottom of a twelve-section page to the top is one drag rather than eleven clicks and eleven preview refreshes. The up and down buttons stay for the keyboard.
Safi\Atelier\PageResolver. One implementation of "which page is this URL", including the rule that the first segment is a locale only when it names one. The public controller and the editor both call it.
The preview's editor script is injected by the controller, not carried by the layout. It sat inside the shipped layout behind an [@if](https://github.com/if) ($preview), so every layout needed a copy and a hand-written one silently lost section-clicking. A layout now carries nothing for the editor beyond data-atelier-canvas.
Forms inside the preview no longer submit. A form in a block used to post for real from the editor, against a page that may not be published yet.
The home page had two URLs and the canonical pointed at the wrong one. The page whose slug is home is served at /, but Page::url() built /home, and that is what the canonical tag, the hreflang alternates, the sitemap entry and every menu item built from the home page carried. Duplicate content with the canonical naming the copy, on a package that sells itself on SEO. url() now returns the root of the locale and /home 301s to it.
A URL typed in the panel could carry a javascript: scheme into an href. Blade escaping stops the value breaking out of the attribute and does nothing about the scheme, so a link pasted into a hero, a call to action, a logo wall or a menu item was stored XSS against every visitor. Url::safe() allows http, https, mailto, tel and sms and returns # otherwise. Call it in your own blocks wherever a client-supplied URL reaches an href.
Every link in the preview opened in a new tab when APP_URL did not match the address being browsed. The ordinary local setup, APP_URL naming localhost while somebody browses 127.0.0.1, treated the whole site as external.
The wiki is current, including a new Page types guide. example/ is a complete demo site with DEMO.md as its tour.
Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.5.0...v1.0.0
A bug fix. No migration, no config change, no new table. composer update safi/filament-atelier is the whole upgrade.
The preview iframe kept showing the old locale after switching languages. Its refresh
fetch read a preview URL cached in the Alpine component at page load rather than the
iframe's current src. Livewire moves the iframe's src to the new locale on every
render; the cached copy never followed, so each refresh re-fetched the old locale and
painted it back over the iframe. The fetch now reads the iframe's own src.
The same locale switch could also fail the preview request outright, independent of the
fix above. setLocale() called persist(), which writes the block tree, unchanged,
there is nothing to save, and dispatches atelier-refresh, firing a second fetch at the
exact preview URL the iframe's src change was already navigating to. Both requests carry
the same session cookie and race each other, and the loser can fail. setLocale() no
longer calls persist(); the iframe's own navigation is enough.
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.Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.2.0...v0.3.0
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 nothing to change: every existing page keeps rendering through config('atelier.layout') exactly as before.
A site is rarely one shell. Marketing pages want a navbar and a 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 rather than a different set of blocks.
Register the layouts you have written where you register blocks:
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. A Layout dropdown appears on the page settings screen, and hides itself entirely when no layouts are registered, because a question with one answer is not a question. Leaving it empty uses the site-wide atelier.layout.
A working example ships in example/resources/views/layouts/docs.blade.php, and the Layouts guide covers writing one.
A layout is a map, not a class. Blocks earn a class because they carry a schema, an icon, a category and translatable keys. A layout is a key, a label and a view, and 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 same reason Arabic cannot have a different section order from English.
An unknown key falls back rather than throwing. Delete a layout from your panel provider and pages still naming it render on the site-wide default. A page keeps its key after the code that defined it is gone, and a 500 on every public page is a bad way to find that out.
The preview never passed $page to the layout, so a custom layout reading it worked on the live site and 500'd in the preview. That is exactly the failure the shared render path exists to prevent, and it was found by the test asserting a preview renders through the same layout as the public page.
Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.1.6...v0.2.0
Makes the sitemap readable in a browser. No migration, no API change, nothing to do but update.
Opening /sitemap.xml in a browser rendered a wall of unlabelled URLs and timestamps in a serif font, which reads as broken.
The XML itself was never wrong: it starts with <?xml, is served as application/xml; charset=UTF-8, carries no BOM and parses. What was missing is the thing every serious sitemap ships and this one did not, an <?xml-stylesheet?> instruction. Browsers apply it and get a table of URLs, last-modified dates and locale alternates. Crawlers ignore the instruction entirely, so the machine-readable side is untouched.
The stylesheet is served from the package at /sitemap.xsl rather than published into your public/ directory, so it cannot drift from the XML it renders and there is nothing extra to publish.
Verified by running the stylesheet through xsltproc the way a browser does, rather than by asserting the string is present in the output.
Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.1.5...v0.1.6
The SEO release. A sitemap, robots.txt, per-page noindex, and 301s when a slug changes.
This release adds a table. After updating:
composer update safi/filament-atelier
php artisan vendor:publish --tag=filament-atelier-migrations
php artisan migrate
Skip it and renaming a slug fails on a missing atelier_page_redirects.
Renaming a published page used to 404 every inbound link to it, silently and permanently. Changing a slug now writes a 301 from the old one, and the public route consults those before it gives up.
The redirect stores the page rather than a target slug, so a page renamed twice sends both old URLs to wherever it lives now, with no chain to follow and nothing to prune. A new page claiming a freed slug drops the redirect from it, because whoever claims a slug owns it. An unpublished target still 404s: sending someone to a 404 is worse than the 404 itself.
/sitemap.xml lists every published, indexable page in every locale, with xhtml:link alternates and lastmod from published_at, which the table already stored and nothing read.
Written rather than pulled in. spatie/laravel-sitemap is a good package, but its job is crawling a site to discover URLs, and Atelier already knows every URL it has.
A client site is rarely only Atelier pages. A blog or a services resource with its own model, its own panel tab and its own routes has URLs that belong in the same sitemap:
AtelierPlugin::make()
->blocks(DefaultBlocks::all())
->sitemap([
fn () => Post::published()->get()->map(fn (Post $post) => [
'loc' => route('blog.show', $post),
'lastmod' => $post->updated_at,
]),
]);
A source is a closure or the name of an invokable class resolved from the container, deliberately the same shape as ->blocks(). Each returns URL strings, or arrays with lastmod and per-locale alternates. Sources run when the sitemap is requested, so they are free to query. Entries dedupe on loc. A source that throws fails the sitemap rather than silently shrinking it.
Two independent toggles per locale. Hide from search engines emits the robots tag and drops that URL from the sitemap, one switch for both effects, decided per locale so an English page can be listed while its Arabic translation is not, with the alternate dropping too. Nofollow is separate, because a page can be indexed and still not pass link credit.
The tag is emitted only when it says something. index, follow is what every crawler assumes anyway.
Served at /robots.txt, pointing at the sitemap and disallowing the preview route and the panel, with atelier.robots.disallow_panel for the panel path.
Laravel ships a real public/robots.txt, and a file on disk is served before any route runs. Delete it to use this one, or copy the Sitemap: line into yours.
Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.1.4...v0.1.5
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. The Installation guide covers it in full.
Replacing the layout silently lost every meta tag. Title, description, canonical, hreflang and Open Graph lived inside atelier::layouts.site. config('atelier.layout') invites an app to replace that view, and any app that did rendered pages with none of them, while previews stopped being noindex. The page looked perfect, which is why this went unnoticed.
The social share image upload was missing ->visibility('public'). It worked on a local disk and 403'd on S3, so the Open Graph tag pointed at an image no crawler could read.
atelier::partials.meta and atelier::partials.tokens. 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.
Three tests cover this, and the useful one renders the same page through the stock layout and through a host app's layout, then asserts both emit an identical head. That is the test that fails if someone adds a tag to one and forgets the other.
The README described features that do not exist: GSAP animations, a sitemap, JSON-LD, per-block asset loading, and header, footer, contact form and raw HTML blocks. It also said Filament v4 while the package requires ^5.0. It now lists what ships and, separately, what does not.
Documentation moved to the wiki. 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.
Animation is no longer a plugin feature. It belongs to whoever writes the block: a block is already your PHP class and your Blade view, so it animates however you like, and Atelier ships no GSAP dependency and no preset contract. The cost, stated plainly, is that there is no animation dropdown for the client.
robots.txt or JSON-LD. Meta, canonical, hreflang and Open Graph are in; structured data is not.Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.1.3...v0.1.4
Repository hygiene, not package code. Nothing in src/ changed, so there is no migration and no upgrade step beyond composer update.
A security policy. SECURITY.md, with GitHub private vulnerability reporting enabled on the repository so the advisory link in it resolves. It scopes in what actually matters for this package: template injection through block content, draft content reaching an unauthenticated visitor, a preview link resolving without a valid signature, and slug handling serving a page the request did not ask for. It scopes out the raw HTML block executing HTML somebody deliberately typed, because that is the feature.
CI. The 44 tests ran only when someone remembered to. They now run on every push and pull request, with Pint checking formatting.
Third-party actions are pinned to full 40-character commit SHAs, never tags, with the version in a trailing comment. A tag can be silently re-pointed at malicious code by whoever controls the action's repository, which is how tj-actions/changed-files exfiltrated CI secrets from thousands of repositories in March 2025.
The suite runs on PHP 8.4, because Pest 5 requires ^8.4 and cannot run on the ^8.3 floor the package supports. Rather than drop 8.3 quietly and leave a public promise nothing verifies, a separate job checks the two halves that constraint is made of: that src, config, routes and database parse under 8.3, and that a consumer on 8.3 can resolve and install the package.
A dependency updater. .github/dependabot.yml covers the workflows weekly with a seven-day cooldown. Updating the moment a release lands is exactly the window a malicious release relies on, and no lockfile is committed here deliberately, so the workflows are the only thing an updater has to maintain.
The test suite no longer needs a built front end. Tests assert on rendered HTML and never on the asset bundle, so withoutVite() lets the suite run on a clean checkout instead of failing on a missing Vite manifest.
Unchanged from v0.1.2:
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.Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.1.2...v0.1.3
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. After updating:
composer update safi/filament-atelier
php artisan vendor:publish --tag=filament-atelier-migrations
php artisan migrate
New tables ship as new migration files rather than as edits to one that already ran on your database, so vendor:publish copies only what is new and never touches a migration you have edited. Skip it and atelier_page_revisions is missing, which surfaces the first time someone publishes a page.
Design tokens. Colour, font, spacing and width are emitted as CSS custom properties into the head of both the public page and the editor preview, so the two cannot drift. Defaults ship in 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, so a block author never learns tokens exist.
An Arabic font stack, swapped in by a [dir="rtl"] rule rather than by locale code, so a third RTL language gets it free.
Shared section controls. A block declares 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: it would compile in a test app and silently vanish on a client site. Leaving a control unset keeps the block's own styling.
Page revisions. Every publish snapshots the tree that went live into 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.
An Unpublish action on the page settings screen. Page::unpublish() existed and nothing called it, so taking a page down meant editing the database.
A nested slug such as 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.
Block views take a $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.
The contact form block is settled as presentational: it posts to a route the developer wires per site. Atelier owns no submissions table, so it never quietly becomes a data processor on a client site.
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.Full changelog: https://github.com/Abdulkader-Safi/filament-atelier/compare/v0.1.1...v0.1.2
How can I help you explore Laravel packages today?