A polymorphic content block builder for FilamentPHP v5 with first-class translation support, EAV attribute storage, and Schema.org structured data generation.
Atelier stores block data as polymorphic EAV (Entity-Attribute-Value) rows, keeping your application schema lean while supporting arbitrary block structures. Translatable fields are stored per-locale, and the schema is scanned at save time to determine translatability automatically.
- PHP 8.2+
- Laravel 11+
- FilamentPHP 5.0+
composer require blackpig-creatif/atelierAtelier automatically pulls in its companion packages:
- Chambre Noir -- responsive image processing
Publish and run migrations:
php artisan vendor:publish --tag="atelier-migrations"
php artisan migratePublish the config:
php artisan vendor:publish --tag="atelier-config"Register the Filament plugin in your PanelProvider:
use BlackpigCreatif\Atelier\AtelierPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
AtelierPlugin::make(),
]);
}# Block Blade templates (recommended -- customise frontend rendering)
php artisan vendor:publish --tag="atelier-block-templates"
# Divider SVG components
php artisan vendor:publish --tag="atelier-dividers"use BlackpigCreatif\Atelier\Concerns\HasAtelierBlocks;
class Page extends Model
{
use HasAtelierBlocks;
}This gives you:
$page->blocks; // MorphMany -- all blocks, ordered
$page->publishedBlocks; // MorphMany -- only is_published = true
$page->renderBlocks(); // string -- rendered HTML of all published blocks
$page->renderBlocks('fr');use BlackpigCreatif\Atelier\Forms\Components\BlockManager;
use BlackpigCreatif\Atelier\Collections\BasicBlocks;
public static function form(Form $form): Form
{
return $form->schema([
BlockManager::make('blocks')
->blocks(BasicBlocks::class)
->collapsible()
->reorderable(),
]);
}{{-- Blade directive --}}
@renderBlocks($page)
{{-- Or manually --}}
@foreach($page->publishedBlocks as $block)
{!! $block->render() !!}
@endforeach
{{-- With explicit locale --}}
@foreach($page->publishedBlocks as $block)
{!! $block->render('fr') !!}
@endforeach| Block | Description | Schema contribution |
|---|---|---|
| HeroBlock | Full-width hero with background image, headline, CTAs | None |
| TextBlock | Rich text with optional title, subtitle, column layout | Article body text |
| TextWithImageBlock | Text + single image, configurable position | Article body text + image URL |
| TextWithTwoImagesBlock | Rich text + two images, multiple layout modes | Article body text + image URLs |
| ImageBlock | Single image with caption, aspect ratio, lightbox | None |
| VideoBlock | YouTube/Vimeo/direct URL embed with auto-detection | VideoObject schema |
| GalleryBlock | Grid gallery with configurable columns and lightbox | Image URLs for Article |
| CarouselBlock | Image carousel with navigation and autoplay | Image URLs for Article |
| FaqsBlock | Accordion FAQ list | FAQPage schema |
Collections group blocks into reusable sets.
| Collection | Blocks |
|---|---|
BasicBlocks |
Hero, Text, TextWithImage |
MediaBlocks |
Image, Video, Gallery, Carousel |
AllBlocks |
All built-in blocks |
// Single collection
->blocks(BasicBlocks::class)
// Multiple collections
->blocks([BasicBlocks::class, MediaBlocks::class])
// Mix collections with individual blocks
->blocks([BasicBlocks::class, CustomBlock::class])
// Closure for dynamic logic
->blocks(fn () => auth()->user()->isAdmin()
? AllBlocks::make()
: BasicBlocks::make()
)php artisan atelier:make-collection EcommerceOr manually:
namespace App\BlackpigCreatif\Atelier\Collections;
use BlackpigCreatif\Atelier\Abstracts\BaseBlockCollection;
use BlackpigCreatif\Atelier\Blocks\HeroBlock;
use App\BlackpigCreatif\Atelier\Blocks\ProductBlock;
class EcommerceBlocks extends BaseBlockCollection
{
public function getBlocks(): array
{
return [
HeroBlock::class,
ProductBlock::class,
];
}
public static function getLabel(): string
{
return 'E-commerce Blocks';
}
}Atelier supports two layers of configuration: field configuration (tweak individual field properties) and schema modification (add, remove, or reorder fields structurally). Both can be applied globally or per-resource.
For the full reference with all helper methods and patterns, see docs/block-configuration.md.
Register global defaults in a service provider:
namespace App\Providers;
use BlackpigCreatif\Atelier\Blocks\HeroBlock;
use BlackpigCreatif\Atelier\Blocks\TextBlock;
use BlackpigCreatif\Atelier\Support\BlockFieldConfig;
use Filament\Forms\Components\Toggle;
use Illuminate\Support\ServiceProvider;
class AtelierServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Configure individual fields
BlockFieldConfig::configure(TextBlock::class, [
'subtitle' => ['visible' => false],
'columns' => ['options' => ['1' => '1 Column', '2' => '2 Columns']],
]);
// Modify schema structure
BlockFieldConfig::modifySchema(HeroBlock::class, function ($schema) {
return BlockFieldConfig::removeFields($schema, ['text_color', 'overlay_opacity']);
});
// Add fields
BlockFieldConfig::modifySchema(HeroBlock::class, function ($schema) {
return [
...$schema,
Toggle::make('featured')->label('Featured')->default(false),
];
});
}
}Register it in bootstrap/providers.php.
Override globals on a specific resource:
BlockManager::make('blocks')
->blocks([HeroBlock::class, TextBlock::class])
// Field config (array form)
->configureBlock(HeroBlock::class, [
'headline' => ['maxLength' => 60],
'ctas' => ['maxItems' => 5],
])
// Schema modifier (closure form)
->configureBlock(HeroBlock::class, fn ($schema) =>
BlockFieldConfig::removeFields($schema, 'subtitle')
)Block Default Schema
-> Global Schema Modifiers
-> Per-Resource Schema Modifiers
-> Global Field Configs
-> Per-Resource Field Configs (wins)
Schema modifiers shape the structure first; field configs tweak properties last. Per-resource always overrides global at the same level.
For a chainable alternative:
use BlackpigCreatif\Atelier\Support\BlockConfigurator;
BlockConfigurator::for(HeroBlock::class)
->hide('overlay_opacity', 'text_color')
->remove('height')
->configure('ctas', ['maxItems' => 2])
->insertAfter('headline', [
TextInput::make('tagline')->maxLength(100),
])
->apply();php artisan atelier:make-block QuoteCreates the block class and Blade template with the correct boilerplate.
namespace App\BlackpigCreatif\Atelier\Blocks;
use BlackpigCreatif\Atelier\Abstracts\BaseBlock;
use BlackpigCreatif\Atelier\Concerns\HasCommonOptions;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Illuminate\Contracts\View\View;
class QuoteBlock extends BaseBlock
{
use HasCommonOptions;
public static function getLabel(): string
{
return 'Quote';
}
public static function getIcon(): string
{
return 'heroicon-o-chat-bubble-left-right';
}
public static function getSchema(): array
{
return [
...static::getHeaderFields(),
Section::make('Content')
->schema([
Textarea::make('quote')
->required()
->rows(3)
->translatable(), // must be last in chain
TextInput::make('author')
->required()
->translatable(), // must be last in chain
])
->collapsible(),
...static::getCommonOptionsSchema(),
];
}
public function render(): View
{
return view(static::getViewPath(), $this->getViewData());
}
}Key points:
- Extend
BaseBlockand implementgetLabel(),getSchema(),render() - Use
HasCommonOptionsfor background, spacing, width, and divider controls - Call
->translatable()as the last method in a field chain - Call
...static::getHeaderFields()at the top of your schema (Published toggle + Fragment ID when scroll navigation is enabled) - Call
...static::getCommonOptionsSchema()at the end for display options
Templates live at resources/views/vendor/atelier/blocks/{block-identifier}.blade.php. See docs/block-templates.md for the full template guide.
@php
$blockIdentifier = 'atelier-' . $block::getBlockIdentifier();
@endphp
<section class="{{ $blockIdentifier }} {{ $block->getWrapperClasses() }}"
data-block-type="{{ $block::getBlockIdentifier() }}"
data-block-id="{{ $block->blockId ?? '' }}">
<div class="{{ $block->getContainerClasses() }}">
@if($quote = $block->getTranslated('quote'))
<blockquote class="text-2xl italic">
<p>"{{ $quote }}"</p>
</blockquote>
@endif
@if($author = $block->getTranslated('author'))
<footer class="mt-4 font-semibold">-- {{ $author }}</footer>
@endif
</div>
@if($block->getDividerComponent())
<x-dynamic-component
:component="$block->getDividerComponent()"
:to-background="$block->getDividerToBackground()"
/>
@endif
</section>Atelier provides inline, per-field translation with a global locale switcher in the block modal.
Append ->translatable() as the last call in the field chain:
TextInput::make('headline')
->required()
->maxLength(255)
->translatable();The macro clones the field for each configured locale, wrapping them in a group that responds to the global locale selector via Alpine.js.
Atelier automatically detects translatable fields by scanning the block schema at save time. You do not need to maintain a getTranslatableFields() method. If you do define one, it is used as a performance optimisation on the frontend to avoid schema scanning on render.
// In templates
$block->getTranslated('headline'); // current locale
$block->getTranslated('headline', 'fr'); // explicit localeIn config/atelier.php:
'locales' => [
'en' => 'English',
'fr' => 'Francais',
],
'default_locale' => 'en',Data is stored as one EAV row per locale per field: headline/en, headline/fr.
The HasCallToActions trait adds a repeater-based CTA system to any block.
use BlackpigCreatif\Atelier\Concerns\HasCallToActions;
class HeroBlock extends BaseBlock
{
use HasCallToActions;
public static function getSchema(): array
{
return [
// ... content fields
Section::make('Call to Action')
->schema([
static::getCallToActionsField()
->maxItems(3),
])
->collapsible(),
];
}
}Each CTA item includes: label (translatable), url, icon (Heroicon name), style (from config), new_tab toggle.
@if($block->hasCallToActions())
<div class="flex gap-4">
@foreach($block->getCallToActions() as $index => $cta)
<x-atelier::call-to-action
:cta="$cta"
:block="$block"
:index="$index"
/>
@endforeach
</div>
@endif$block->hasCallToActions(): bool
$block->getCallToActions(): array
$block->getCallToActionLabel($cta, ?string $locale): string
$block->getCallToActionStyleClass($cta): string
$block->getCallToActionTarget($cta): string // '_blank' or '_self'
$block->isExternalUrl(string $url): boolConfigured in config/atelier.php:
'features' => [
'button_styles' => [
'enabled' => true,
'options' => [
'primary' => ['label' => 'Primary', 'class' => 'btn btn-primary'],
'secondary' => ['label' => 'Secondary', 'class' => 'btn btn-secondary'],
'alternate' => ['label' => 'Alternate', 'class' => 'btn btn-alternate'],
],
],
],All blocks using HasCommonOptions gain a collapsible "Display Options" section with:
| Feature | Description | Config key |
|---|---|---|
| Background | Predefined background colours (Tailwind classes + admin colour swatch) | features.backgrounds |
| Spacing | Balanced (equal py-) or individual (pt- / pb-) vertical padding |
features.spacing |
| Width | Container, Narrow, Wide, or Full Width content constraint | features.width |
| Dividers | Decorative SVG dividers (wave, curve, diagonal, triangle) with colour transition to next section | features.dividers |
| Published | Toggle to show/hide on frontend (is_published column) |
-- |
In templates, use $block->getWrapperClasses() on the outer <section> (background + spacing) and $block->getContainerClasses() on the inner <div> (width constraint).
Atelier includes optional in-page scroll navigation, designed for single-page sites with a fixed header nav that links to named sections.
Enable it in config/atelier.php:
'features' => [
'scroll_navigation' => [
'enabled' => true,
'offset' => 80, // fixed nav height in pixels
],
],When enabled, a Fragment ID field appears alongside the Published toggle at the top of every block's edit form. Editors enter a short identifier (e.g. about) and the rendered <section> receives id="about".
The scrollToEl() helper is injected once per page via @push('scripts') when any block is rendered. Add @stack('scripts') before </body> in your layout if it is not already there.
window.scrollToEl = (selector) => {
const el = document.getElementById(selector)
if (!el) return
const y = el.getBoundingClientRect().top + window.scrollY - 80 // offset from config
window.scrollTo({ top: y, behavior: 'smooth' })
}<!-- Plain HTML -->
<a href="#about" onclick="event.preventDefault(); scrollToEl('about')">About</a>
<!-- Alpine.js -->
<button @click="scrollToEl('about')">About</button>$block->getFragmentId(): ?string // returns the stored value, or null if unsetAtelier integrates with Chambre Noir for responsive images. Use RetouchMediaUpload in your schema and the HasRetouchMedia trait on your block:
use BlackpigCreatif\ChambreNoir\Concerns\HasRetouchMedia;
use BlackpigCreatif\ChambreNoir\Forms\Components\RetouchMediaUpload;
use BlackpigCreatif\Atelier\Conversions\BlockHeroConversion;
class HeroBlock extends BaseBlock
{
use HasRetouchMedia;
public static function getSchema(): array
{
return [
RetouchMediaUpload::make('background_image')
->preset(BlockHeroConversion::class)
->imageEditor()
->maxFiles(1),
];
}
}In templates:
{!! $block->getPicture('background_image', [
'alt' => $block->getTranslated('headline'),
'class' => 'w-full h-full object-cover',
'fetchpriority' => 'high',
]) !!}| Preset | Conversions | Use case |
|---|---|---|
BlockHeroConversion |
thumb, medium, large, desktop, mobile + social (og, twitter) | Hero sections, full-width banners |
BlockGalleryConversion |
thumb, medium, large | Galleries, content images, carousels |
The HasAtelierMediaExtraction trait (add to your model) provides convenience methods:
use BlackpigCreatif\Atelier\Concerns\HasAtelierMediaExtraction;
class Page extends Model
{
use HasAtelierBlocks, HasAtelierMediaExtraction;
}
$page->getHeroImageFromBlocks('large');
$page->getImageFromBlock('image', 'medium', ImageBlock::class);Atelier exposes three PHP contracts that blocks implement to contribute to Schema.org output. The contracts are deliberately package-agnostic — Atelier has no dependency on Sceau or any other SEO library.
See docs/schema.md for the full reference.
BaseBlock implements all three contracts with no-op defaults. Override only what your block needs.
HasCompositeSchema — contributes content or media URLs to a composite schema (e.g. Article) assembled from multiple blocks:
public function contributesToComposite(): bool { return true; }
public function getCompositeContribution(): array
{
return [
'type' => 'text',
'content' => strip_tags($this->getTranslated('content') ?? ''),
];
}HasSchemaContribution — declares a typed schema that the active driver converts to a structured data array:
use BlackpigCreatif\Sceau\Enums\SchemaType;
public function getSchemaType(): ?SchemaType
{
return ! empty($this->get('faqs')) ? SchemaType::FAQPage : null;
}
public function getSchemaData(): array
{
return ['faqs' => $this->get('faqs', [])];
}HasStandaloneSchema — legacy escape hatch for blocks that build the full schema array themselves. Prefer the driver pattern for new blocks.
The driver is resolved from the container via BlockSchemaDriverInterface. Configure Sceau's driver in your app config:
// config/atelier.php
'schema_driver' => \BlackpigCreatif\Sceau\Schema\Drivers\SceauBlockSchemaDriver::class,When using Sceau, schema generation is fully automatic — the <x-sceau::head> component calls PageSchemaBuilder::build() for models that carry HasAtelierBlocks.
The config/atelier.php file:
return [
'locales' => ['en' => 'English', 'fr' => 'Francais'],
'default_locale' => 'en',
'modal' => ['width' => '5xl'],
'table_prefix' => 'atelier_',
'blocks' => [
// Default blocks when ->blocks() is called without arguments
],
'schema_driver' => null, // Set to a BlockSchemaDriverInterface class to enable schema generation
'features' => [
'backgrounds' => ['enabled' => true, 'options' => [...]],
'spacing' => ['enabled' => true, 'options' => [...]],
'width' => ['enabled' => true, 'options' => [...]],
'dividers' => ['enabled' => true, 'options' => [...]],
'button_styles' => ['enabled' => true, 'options' => [...]],
'scroll_navigation' => ['enabled' => false, 'offset' => 80],
],
'cache' => [
'enabled' => true,
'ttl' => 3600,
'prefix' => 'atelier_block_',
],
];Atelier uses a polymorphic EAV storage model:
atelier_blocks-- polymorphic (blockable_type,blockable_id), stores block type, position, UUID, published statusatelier_block_attributes-- stores each field value as a row withkey,value,type,locale,translatable,sort_order,collection_name,collection_index
Repeater fields (e.g. CTAs) are stored as collection-based EAV rows, grouped by collection_name and collection_index.
At hydration time, the AtelierBlock model reconstructs the block instance, fills its data array, and caches the result per locale.
- Block Configuration -- full field config and schema modification reference
- Block Templates -- template structure, helper methods, scroll navigation, best practices
- Schema Generation -- schema contracts, built-in contributions, custom block schemas
composer testSee CHANGELOG.
MIT. See LICENSE.