Skip to content
Abdulkader Safi edited this page Aug 18, 2026 · 3 revisions

Usage

The flow

Pages in the panel nav → a page row → its settingsEdit page content → the builder, full screen in a new tab.

Settings and content are deliberately separate screens. Slug and SEO are things you set once; content is what you come back to.

Page settings

  • Title. Internal name, and the fallback for the meta title.
  • A tab per locale, each holding:
    • Slug. Leave empty to generate one from the title.
    • Meta title and meta description.
    • Social share image. 1200 by 630 is the safe size.
    • Canonical URL. Empty means the page's own URL.

Header buttons: Edit page content, View live (published pages only), Publish, Delete.

The builder

Full screen, outside the panel chrome.

Left rail switches between the section list and the inspector.

Sidebar shows one of two things:

  • The section list: every section, labelled by its own heading rather than "Block 4". Hover a row for move up, move down and settings. Add section at the bottom opens the picker, grouped by category.
  • The inspector for the selected section: its fields, with move, duplicate, hide, delete in the header and a back chevron to the list.

Middle is the live preview. It renders the real page through the public layout and the public stylesheet, so it is not an approximation. Click a section in the preview to select it.

Toolbar:

  • Back arrow to the Pages list
  • Status badge: Draft, Published, or Published with unpublished changes
  • Desktop, tablet and mobile widths, at fixed sizes rather than whatever the pane happens to be
  • Locale switcher
  • Open the preview in a new tab, as a signed link that expires
  • Publish

Draft and published

Editing writes the draft, always. The live page reads a separate column and cannot change until you press Publish. An unpublished page 404s on the public site, so a half-finished page can't leak.

Hiding a section keeps it in the editor and removes it from the public page. It's the reversible alternative to deleting.

Layouts

A page can be wrapped in whichever shell suits it: a navbar and footer for marketing, a sidebar for documentation. Register them in the panel provider and the client picks one from a Layout dropdown in page settings. See Layouts.

Writing your own block

One PHP class and one Blade view. Nothing inside the plugin changes.

namespace App\Blocks;

use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Safi\Atelier\Blocks\BaseBlock;
use Safi\Atelier\Media;

class StatsBlock extends BaseBlock
{
    public static function type(): string
    {
        return 'stats';           // registry key, and the view name
    }

    public static function icon(): string
    {
        return 'heroicon-o-chart-bar';
    }

    public static function category(): string
    {
        return 'Content';          // groups it in the picker
    }

    /** Fields stored per locale. */
    public static function translatable(): array
    {
        return ['heading'];
    }

    /** Starting values when the block is added to a page. */
    public static function defaults(): array
    {
        return ['heading' => ['en' => 'By the numbers']];
    }

    public function schema(): array
    {
        return [
            TextInput::make('heading')->live(debounce: 400),
            Textarea::make('body')->rows(3)->live(debounce: 400),
            Media::upload('image', 'Image')->live(),
        ];
    }
}

Then resources/views/blocks/stats.blade.php in your app:

<section data-atelier-block="{{ $id }}" class="px-6 py-16">
    <div class="mx-auto max-w-3xl">
        @if ($heading = $attributes['heading'] ?? null)
            <h2 class="text-3xl font-semibold">{{ $heading }}</h2>
        @endif
    </div>
</section>

And register it:

AtelierPlugin::make()->blocks([
    ...DefaultBlocks::all(),
    \App\Blocks\StatsBlock::class,
])

Four things that will bite you otherwise

  1. ->live(debounce: 400) is what makes the preview update as you type. A field without it only refreshes when focus leaves the field. This is the single most common "why isn't the preview updating".
  2. translatable() fields are stored as {"en": "...", "ar": "..."}. Everything else is shared across locales. Repeaters can be translatable too; then the whole list is per locale.
  3. Use Media::upload() in the schema and Media::url() in the view. Never call Storage::url() yourself. FileUpload state is not reliably a string; it's an array keyed by uuid while editing and [] when empty. Media::url() is where that's handled.
  4. Put data-atelier-block="{{ $id }}" on the outer element. That's what makes clicking a section in the preview select it in the sidebar.

What the view receives

Variable What it is
$attributes The block's fields, already collapsed to the current locale
$id Stable block id
$locale Current locale code
$editing True in the preview, false on the public page
$children Rendered child blocks, for nesting

Use $editing to show something in the editor that shouldn't ship, like an empty-state placeholder.

The blocks that ship

Block Notes
Hero Optional background image, left or centre
Features Repeater, 2 to 4 columns, Heroicon per item
Rich text Filament's rich editor, stored as HTML
Image Container, wide, or full bleed
Gallery Repeater of images, 2 to 4 columns
Logo wall Repeater with optional links
Testimonials Repeater with quote, name, role, photo
FAQ Repeater. Renders as <details>, so it works with JS off
Call to action Heading, text, button

Working on the package itself

Only if you're changing Atelier, not when consuming it:

npm run build                    # rebuild the editor's own stylesheet
cd example && php artisan filament:assets

The editor's utilities ship compiled in resources/dist/atelier.css and are registered through FilamentAsset, so consumers never run a build.

Clone this wiki locally