blackpig-creatif / atelier
L'art du contenu - Artisanal content blocks for FilamentPHP v4
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/blackpig-creatif/atelier
Requires
- php: ^8.2|^8.3|^8.4
- blackpig-creatif/chambre-noir: ^1.0
- filament/filament: ^4.0
- illuminate/contracts: ^11.0|^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- orchestra/testbench: ^9.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpunit/phpunit: ^11.0
README
A polymorphic content block builder for FilamentPHP v4 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.
Requirements
- PHP 8.2+
- Laravel 11+
- FilamentPHP 4.0+
Installation
composer require blackpig-creatif/atelier
Atelier automatically pulls in its companion packages:
- Chambre Noir -- responsive image processing
- Sceau -- SEO and Schema.org structured data
Publish and run migrations:
php artisan vendor:publish --tag="atelier-migrations"
php artisan migrate
Publish 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(), ]); }
Optional Publishables
# 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"
Quick Start
1. Add the trait to your model
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');
2. Add BlockManager to your Filament resource
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(), ]); }
3. Render blocks in your view
{{-- 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
Built-in Blocks
| Block | Description |
|---|---|
| HeroBlock | Full-width hero with background image, headline, CTAs |
| TextBlock | Rich text with optional title, subtitle, column layout |
| TextWithImageBlock | Text + single image, configurable position |
| TextWithTwoImagesBlock | Rich text + two images, multiple layout modes |
| ImageBlock | Single image with caption, aspect ratio, lightbox |
| VideoBlock | YouTube/Vimeo/direct URL embed with auto-detection |
| GalleryBlock | Grid gallery with configurable columns and lightbox |
| CarouselBlock | Image carousel with navigation and autoplay |
Block Collections
Collections group blocks into reusable sets.
Built-in collections
| Collection | Blocks |
|---|---|
BasicBlocks |
Hero, Text, TextWithImage |
MediaBlocks |
Image, Video, Gallery, Carousel |
AllBlocks |
All eight built-in blocks |
Usage
// 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() )
Creating a collection
php artisan atelier:make-collection Ecommerce
Or 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'; } }
Block Configuration
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.
Global Configuration
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.
Per-Resource Configuration
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') )
Configuration Priority
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.
Fluent API (BlockConfigurator)
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();
Creating Custom Blocks
Artisan generator
php artisan atelier:make-block Quote
Creates the block class and Blade template with the correct boilerplate.
Manual creation
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::getPublishedField(), 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::getPublishedField()at the top of your schema for the publish toggle - Call
...static::getCommonOptionsSchema()at the end for display options
Block template
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>
Translation
Atelier provides inline, per-field translation with a global locale switcher in the block modal.
Making fields translatable
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.
Schema scanning
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.
Retrieving translated values
// In templates $block->getTranslated('headline'); // current locale $block->getTranslated('headline', 'fr'); // explicit locale
Configuration
In 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.
Call to Actions (CTAs)
The HasCallToActions trait adds a repeater-based CTA system to any block.
Adding CTAs
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.
Rendering
@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
Helper methods
$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): bool
Button styles
Configured 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'], ], ], ],
Display Options
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).
Media Handling
Atelier 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', ]) !!}
Built-in conversion presets
| 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 |
Extracting images from blocks
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);
SEO Schema Generation
Atelier integrates with Sceau to generate Schema.org JSON-LD from blocks.
use BlackpigCreatif\Sceau\Services\PageSchemaBuilder; public function show(Page $page) { PageSchemaBuilder::build($page); return view('pages.show', ['page' => $page]); }
Blocks contribute via InteractsWithSchema (inherited from BaseBlock):
-
Composite contribution (e.g. TextBlock content feeds into an Article schema):
public function contributesToComposite(): bool { return true; } public function getCompositeContribution(): array { return ['type' => 'text', 'content' => strip_tags($this->get('content'))]; }
-
Standalone schema (e.g. VideoBlock generates a VideoObject):
public function hasStandaloneSchema(): bool { return true; } public function toStandaloneSchema(): ?array { return ['@context' => 'https://schema.org', '@type' => 'VideoObject', ...]; }
Configuration Reference
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 ], 'features' => [ 'backgrounds' => ['enabled' => true, 'options' => [...]], 'spacing' => ['enabled' => true, 'options' => [...]], 'width' => ['enabled' => true, 'options' => [...]], 'dividers' => ['enabled' => true, 'options' => [...]], 'button_styles' => ['enabled' => true, 'options' => [...]], ], 'cache' => [ 'enabled' => true, 'ttl' => 3600, 'prefix' => 'atelier_block_', ], ];
Architecture
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.
Documentation
- Block Configuration -- full field config and schema modification reference
- Block Templates -- template structure, helper methods, best practices
Testing
composer test
Changelog
See CHANGELOG.
Credits
License
MIT. See LICENSE.