Skip to content

Block DTOs

Block DTOs (Data Transfer Objects) are typed wrappers that replace the old ContentResolver → SectionContent → BlockData chain with a simpler, more explicit system.

graph TD
    subgraph "Database"
        JSON["JSON: [{type:'hero', data:{...}}]"]
    end
    
    subgraph "Casting"
        BC["BlockCaster (Eloquent Cast)"]
        BR["BlockRegistry"]
    end
    
    subgraph "DTOs"
        BCol["BlockCollection"]
        BD["BlockData (abstract)"]
        GBD["GenericBlockData"]
    end
    
    subgraph "Frontend"
        SC["SectionComponent"]
        AH["autoHydrate()"]
    end
    
    JSON -->|read attribute| BC
    BC -->|decode + wrap| BCol
    BC -->|uses| BR
    BCol -->|contains| BD
    BD -.->|concrete| GBD
    BCol -->|passed to| SC
    SC -->|maps properties| AH

JFA\FilamentCMSCore\CMS\BlockData is an abstract base class:

abstract class BlockData
{
public function __construct(
public readonly ?array $sourceMap = null,
) {}
abstract public static function fromArray(array $data, ?array $sourceMap = null): self;
abstract public function toArray(): array;
abstract public function get(string $key): mixed;
abstract public function getSourceMap(): ?array;
abstract public function getFieldLabels(): array;
}

The default concrete implementation:

use JFA\FilamentCMSCore\CMS\GenericBlockData;
$block = new GenericBlockData(
data: ['headline' => 'Hello', 'label' => 'Welcome'],
sourceMap: ['sectionId' => 1, 'blockType' => 'hero'],
fieldLabels: ['headline' => 'Headline', 'label' => 'Label'],
);
$block->get('headline'); // 'Hello'
$block->toArray(); // ['headline' => 'Hello', 'label' => 'Welcome']
$block->getSourceMap(); // ['sectionId' => 1, 'blockType' => 'hero']
$block->getFieldLabels(); // ['headline' => 'Headline', 'label' => 'Label']

JFA\FilamentCMSCore\CMS\BlockCollection is an iterable container:

use JFA\FilamentCMSCore\CMS\BlockCollection;
$collection = new BlockCollection($dataArray, registry: app(BlockRegistry::class));
// Get first block (any type)
$first = $collection->first();
// Get first block of a type
$hero = $collection->first('hero');
// Get all blocks of a type
$paragraphs = $collection->all('paragraph');
// Check existence
$collection->has('hero'); // true/false
// Count
$collection->count(); // int
// Check if empty
$collection->isEmpty(); // true/false
// Iterate
foreach ($collection->all() as $block) {
echo $block->get('headline');
}
// Convert to array
$array = $collection->toArray();

JFA\FilamentCMSCore\CMS\BlockRegistry maps block types to DTO classes:

use JFA\FilamentCMSCore\CMS\BlockRegistry;
$registry = app(BlockRegistry::class);
// Register a DTO class for a block type
$registry->register('hero', HeroBlock::class);
// Resolve a block type
$blockClass = $registry->resolve('hero'); // HeroBlock::class

For most use cases, GenericBlockData is used automatically. Custom DTO classes are optional — they provide typed properties but aren’t required.

JFA\FilamentCMSCore\CMS\BlockCaster is an Eloquent cast:

// In ContentModel (base class for Section):
protected function casts(): array
{
return [
'content' => BlockCaster::class,
];
}
$section = Section::find(1);
// Reading: JSON → BlockCollection
$collection = $section->content; // BlockCollection
// Writing: BlockCollection → JSON
$section->content = $newCollection;
$section->save(); // Saves as JSON

JFA\FilamentCMSCore\Forms\Blocks\BlockSchemaExtractor auto-extracts labels from ContentBlock schemas:

graph LR
    A[ContentBlock Class] -->|make| B[Filament Block]
    B -->|getChildSchema| C[Form Components]
    C -->|getLabel| D["Labels Map"]
    D -->|attached to| E[BlockData]
use JFA\FilamentCMSCore\Forms\Blocks\BlockSchemaExtractor;
// Extract labels for a block type
$labels = BlockSchemaExtractor::getLabels('hero');
// Returns: ['label' => 'Label', 'headline' => 'Headline', ...]
// Labels are cached per block type (static cache)
// Clear cache in tests:
BlockSchemaExtractor::clearCache();
  1. You define ->label('Headline') on form components in your ContentBlock
  2. BlockSchemaExtractor reads the schema at resolution time
  3. Labels are attached to each BlockData via getFieldLabels()
  4. renderField() uses these labels for visual editing tooltips

No manual fieldLabels() method needed!

SectionComponent::autoHydrate() maps block data to public properties:

graph TD
    A[BlockData] -->|toArray| B["Array: headline → 'Hello'"]
    B -->|snake_case → camelCase| C["Property: $headline = 'Hello'"]
    C -->|reflection cache| D[Component Instance]
// In SectionComponent
protected function autoHydrate(): void
{
$data = $this->block?->first()?->toArray() ?? [];
foreach ($data as $key => $value) {
$property = $this->camelCase($key); // primary_cta_text → primaryCtaText
if ($this->isNativeType($property)) {
$this->{$property} = $value;
}
}
}

autoHydrate() only sets properties with native PHP types:

TypeAuto-HydratedExample
stringYes$label, $headline
intYes$count
floatYes$price
boolYes$isActive
arrayYes$members, $features
mixedYes$data
CollectionNo$serviceModels
Custom objectsNo$customData

For non-native types, use initializeVisualEditing():

protected function initializeVisualEditing(): void
{
$this->serviceModels = Service::whereIn('id', $ids)->get();
}

autoHydrate() caches the property map per class:

// First call: builds reflection map
// Subsequent calls: uses cached map
// Cache key: static::class

BlockCollection can’t be serialized by Livewire directly. The base class handles this:

// SectionComponent has:
public array $blockData = []; // Livewire-serializable raw data
// mount() syncs both:
public function mount($block = []): void
{
$this->block = $block instanceof BlockCollection ? $block : ...;
$this->blockData = $this->block?->toArray() ?? [];
$this->autoHydrate();
$this->initializeVisualEditing();
}
// hydrate() reconstructs after Livewire restore:
public function hydrate(): void
{
if (! empty($this->blockData)) {
$this->block = new BlockCollection(
$this->blockData,
registry: app(BlockRegistry::class),
);
}
}

Critical: protected properties are not serialized by Livewire. Always use public for properties that need to survive re-renders.

For full type safety, create custom DTO classes:

namespace App\CMS\DTOs;
use JFA\FilamentCMSCore\CMS\BlockData;
final class HeroBlock extends BlockData
{
public function __construct(
public readonly string $label = '',
public readonly string $headline = '',
public readonly string $primaryCtaText = '',
public readonly string $primaryCtaUrl = '',
?array $sourceMap = null,
array $fieldLabels = [],
) {
parent::__construct($sourceMap);
}
public static function fromArray(array $data, ?array $sourceMap = null): self
{
return new self(
label: $data['label'] ?? '',
headline: $data['headline'] ?? '',
primaryCtaText: $data['primary_cta_text'] ?? '',
primaryCtaUrl: $data['primary_cta_url'] ?? '',
sourceMap: $sourceMap,
);
}
public function toArray(): array
{
return [
'label' => $this->label,
'headline' => $this->headline,
'primary_cta_text' => $this->primaryCtaText,
'primary_cta_url' => $this->primaryCtaUrl,
];
}
public function get(string $key): mixed
{
return match ($key) {
'label' => $this->label,
'headline' => $this->headline,
'primary_cta_text' => $this->primaryCtaText,
'primary_cta_url' => $this->primaryCtaUrl,
default => null,
};
}
}

Register it:

// In a service provider or config
app(BlockRegistry::class)->register('hero', HeroBlock::class);

Now $collection->first('hero') returns a HeroBlock with typed properties:

$hero = $collection->first('hero');
$hero->label; // string — IDE autocomplete works!
$hero->headline; // string
AspectOld SystemNew System
ResolutionContentResolver classBlockCaster Eloquent cast
ContainerSectionContentBlockCollection
Block access__get magic$block->get()
Null handlingNullBlockDatanull from first()
HydrationManual hydrateFromContent()Automatic autoHydrate()
LabelsManual fieldLabels()Auto-extracted BlockSchemaExtractor
OrderingGrouped by typeOriginal array order preserved