-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
| 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>
@endif1. ->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 while the Arabic tab is open changes English 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.
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.
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 SEO.
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.
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.
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-viewsRebuild 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.
Building
Running a site
Reference