Skip to content
Abdulkader Safi edited this page Aug 20, 2026 · 4 revisions

Blocks

A block type is one PHP class and one Blade view. The class says what it is called, how it appears in the picker, and what fields it has. The view says what it renders. Nothing inside the plugin changes when you add one.

This is the line the whole product is drawn along: the developer owns the markup and the quality, the client owns the content and the arrangement. It is what stops a page builder turning into a tool for producing pages nobody wants to look at.

The contract

public static function type(): string;         // registry key, and the view name
public static function label(): string;        // shown in the picker
public static function icon(): string;         // Heroicon name
public static function category(): string;     // groups it in the picker
public function schema(): array;               // Filament components = the settings form
public static function supports(): array;      // shared controls it opts into
public static function translatable(): array;  // which fields are stored per locale
public static function defaults(): array;      // starting values when added to a page
public static function view(): string;         // defaults to atelier::blocks.{type}

BaseBlock implements everything except type() and schema(), so a block is usually the handful of methods that differ. Implement Safi\Atelier\Block directly if you want none of that.

schema() returning a plain Filament schema is the biggest saving in the project. The entire control system comes free, and any Filament field works: text, select, toggle, rich editor, file upload, repeater, key-value.

Writing one

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
    }

    /** Shared controls this block opts into. */
    public static function supports(): array
    {
        return ['background', 'padding'];
    }

    /** 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 {{ $shared->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

        @if ($body = $attributes['body'] ?? null)
            <p class="mt-3 text-neutral-600">{{ $body }}</p>
        @endif
    </div>
</section>

And register it:

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

That is the whole loop. The block appears in the picker under Content, with a working settings form and a live preview.

What the view receives

Variable What it is
$attributes The block's fields, collapsed to the current locale and token-resolved
$shared Attribute bag for the root element: the block id, plus supports() styling
$id Stable block id
$locale Current locale code
$editing True in the preview, false on the public page
$children Rendered child blocks, for nesting
$block The block instance itself
$node The raw tree node, before localisation

$editing is for showing something in the editor that should not ship:

@if (! $src && $editing)
    <p class="p-6 text-sm text-neutral-500">Pick an image to see this section.</p>
@endif

Five 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 it. This is the most common "why isn't the preview updating".

2. Put {{ $shared }} on the outer element, usually as $shared->class([...]). It carries the block id that click-to-select needs, and the inline styles from any control the block declared in supports(). Writing data-atelier-block="{{ $id }}" by hand still works, the block just never receives the shared controls.

3. translatable() fields are stored as {"en": "...", "ar": "..."}. Everything else is shared across every locale, so editing it with a translation tab open changes the default locale too. Repeaters can be translatable, and then the whole list is per locale.

4. 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 is an array keyed by uuid while editing, [] when empty, and only becomes a path after Filament's dehydration hooks run. Media::url() is where all of that is handled, and it passes through anything that is already a URL.

5. Reference design tokens, not literal colours. A field storing {"token": "color.primary"} is resolved to var(--atelier-color-primary) before your view runs, so changing the token restyles every page using it. See Design tokens.

Shared controls

A block opts into controls it does not have to build:

public static function supports(): array
{
    return ['background', 'padding'];
}

They appear in a collapsed Section style group under the block's own fields, and the renderer applies them to whatever element carries $shared.

Support What the client gets
background A colour picked from the design tokens
padding Vertical space: none, tight, normal, loose

Every control emits an inline style built from tokens, never a utility class. A class written in PHP is a class Tailwind never scans, so it would work in your app and vanish on someone else's. Leaving a control unset changes nothing, so the block keeps its own styling.

Animation is deliberately not on this list. It belongs in your block's own view, where you can do whatever you like without a preset system in the way.

Contributing structured data

A block can describe itself in JSON-LD, built from data the client already typed into it:

use Safi\Atelier\Schema\StructuredData;

public static function structuredData(array $attributes, string $locale, string $url): array
{
    return [[
        '@type' => 'FAQPage',
        '@id' => StructuredData::id($url, 'faq'),
        'inLanguage' => $locale,
        'mainEntity' => collect($attributes['items'] ?? [])->map(fn (array $item) => [
            '@type' => 'Question',
            'name' => $item['question'],
            'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['answer']],
        ])->all(),
    ]];
}

Return a list of nodes. The attributes arrive collapsed to $locale with tokens resolved, exactly as your view receives them, so the schema cannot describe something different from what rendered. Nodes sharing an @id merge, which is how two FAQ blocks on one page produce one FAQPage rather than two.

A hidden section contributes nothing, because it is not on the page.

You do not have to implement this. Anything a block does not describe can be typed on the page settings screen under Structured data, per locale, and typed entries win over derived ones. That is the normal path for 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. See Structured data.

Nesting

A block can render children:

<section {{ $shared->class(['px-6 py-16']) }}>
    <div class="mx-auto grid max-w-5xl gap-6 md:grid-cols-2">
        {!! $children !!}
    </div>
</section>

$children is the already-rendered HTML of the node's children array. The editor has no UI for nesting yet, so this is for blocks that build their own children programmatically.

The blocks that ship

DefaultBlocks::all() returns all nine. Pass a subset to cherry-pick.

Block Notes
Hero Optional background image, left or centre aligned
Features Repeater, 2 to 4 columns, a 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 JavaScript off
Call to action Heading, text, button

Not built yet: header, footer, contact form and raw HTML. The contact form will be presentational, posting to a route you wire yourself, because your app already knows how to store a submission and a page builder should not quietly become a data processor.

Overriding a shipped block's view

type() maps to atelier::blocks.{type}, so publishing the package views and editing them changes what a shipped block renders:

php artisan vendor:publish --tag=filament-atelier-views

Rebuild your CSS afterwards. Views you publish live in your app, which Tailwind already scans, so the @source line no longer covers them.

For anything beyond a tweak, write your own block class instead and leave the shipped one alone. A published view is a fork you have to maintain against every upgrade.

Clone this wiki locally