diff --git a/README.md b/README.md
index 47d874a..e572475 100644
--- a/README.md
+++ b/README.md
@@ -157,6 +157,31 @@ To add Builder options to the constructor, add them when defining the constructo
->collapsed()
```
+## Block settings
+
+Every block automatically gets a collapsible **Block settings** section with extra fields, so you can label, anchor, and control the visibility of each block:
+
+- `block_name` – a free-text name for the block. It is used as the block's label in the builder (falling back to the block `title()` when empty).
+- `block_anchor` – a URL slug, auto-generated as you type. Handy as an `id` for in-page anchor links.
+- `is_active` – a checkbox, checked by default. When unchecked the block is not rendered.
+- `starts_at` / `ends_at` – optional datetimes. The block is only rendered while the current time is within the window (both bounds inclusive). Leave either empty for an open-ended schedule.
+
+`ConstructorBlockRenderer` skips any block that is inactive or outside its `starts_at`/`ends_at` window, so no extra checks are needed in your views. Blocks saved before these fields existed have no `is_active` value and remain visible.
+
+All values are stored in the block's data and are also available in the block view:
+
+```blade
+
+```
+
+The field labels are translatable. Publish the translations to customise or localise them:
+
+```bash
+php artisan vendor:publish --tag="filament-constructor-translations"
+```
+
## Testing
```bash
diff --git a/resources/lang/en/block-settings.php b/resources/lang/en/block-settings.php
new file mode 100644
index 0000000..a8eaba9
--- /dev/null
+++ b/resources/lang/en/block-settings.php
@@ -0,0 +1,10 @@
+ 'Block settings',
+ 'name' => 'Name',
+ 'anchor' => 'Anchor',
+ 'is_active' => 'Active',
+ 'starts_at' => 'Starts at',
+ 'ends_at' => 'Ends at',
+];
diff --git a/src/Constructor/ConstructorBlockRenderer.php b/src/Constructor/ConstructorBlockRenderer.php
index 614c76a..7ed786d 100644
--- a/src/Constructor/ConstructorBlockRenderer.php
+++ b/src/Constructor/ConstructorBlockRenderer.php
@@ -2,6 +2,7 @@
namespace CubeAgency\FilamentConstructor\Constructor;
+use Carbon\Carbon;
use CubeAgency\FilamentConstructor\Constructor\Blocks\BlockRendererInterface;
use Illuminate\Support\Arr;
@@ -14,6 +15,10 @@ public function render(?array $blocks = []): string
$html = '';
foreach ($blocks as $block) {
+ if (! $this->isBlockVisible($block)) {
+ continue;
+ }
+
$html .= $this->getConstructorBlockHtml($block);
}
@@ -32,6 +37,27 @@ protected function getConstructorBlockHtml(object $block): string
return '';
}
+ protected function isBlockVisible(object $block): bool
+ {
+ $data = (array) ($block->data ?? []);
+
+ if (array_key_exists('is_active', $data) && ! $data['is_active']) {
+ return false;
+ }
+
+ $now = now();
+
+ if (filled($data['starts_at'] ?? null) && $now->lt(Carbon::parse($data['starts_at']))) {
+ return false;
+ }
+
+ if (filled($data['ends_at'] ?? null) && $now->gt(Carbon::parse($data['ends_at']))) {
+ return false;
+ }
+
+ return true;
+ }
+
protected function getBlockRendererClass(string $name): string
{
return Arr::get($this->blocks, $name);
diff --git a/src/Filament/Forms/Components/Constructor.php b/src/Filament/Forms/Components/Constructor.php
index e0b1311..dd79496 100644
--- a/src/Filament/Forms/Components/Constructor.php
+++ b/src/Filament/Forms/Components/Constructor.php
@@ -4,6 +4,13 @@
use CubeAgency\FilamentConstructor\Constructor\Blocks\BlockRenderer;
use Filament\Forms\Components\Builder;
+use Filament\Forms\Components\Checkbox;
+use Filament\Forms\Components\DateTimePicker;
+use Filament\Forms\Components\TextInput;
+use Filament\Schemas\Components\Grid;
+use Filament\Schemas\Components\Section;
+use Filament\Schemas\Components\Utilities\Set;
+use Illuminate\Support\Str;
class Constructor extends Builder
{
@@ -23,6 +30,11 @@ public function use(array $blocks): static
return $this;
}
+ public static function slugifyAnchor(?string $state): ?string
+ {
+ return filled($state) ? Str::slug($state) : null;
+ }
+
protected function setBlocks(array $blocks): void
{
$blockFields = [];
@@ -33,8 +45,13 @@ protected function setBlocks(array $blocks): void
*/
$blockRenderer = resolve($block);
$block = Builder\Block::make($blockRenderer->name())
- ->label($blockRenderer->title())
- ->schema($blockRenderer->schema());
+ ->label(fn (?array $state): string => filled($state['block_name'] ?? null)
+ ? $state['block_name']
+ : $blockRenderer->title())
+ ->schema([
+ ...$blockRenderer->schema(),
+ $this->blockSettingsSection(),
+ ]);
$icon = $blockRenderer->icon();
if ($icon) {
@@ -57,4 +74,36 @@ protected function setBlocks(array $blocks): void
$this->blocks($blockFields);
$this->blockIcons();
}
+
+ protected function blockSettingsSection(): Section
+ {
+ return Section::make(__('filament-constructor::block-settings.title'))
+ ->schema([
+ Checkbox::make('is_active')
+ ->label(__('filament-constructor::block-settings.is_active'))
+ ->default(true),
+ Grid::make()
+ ->schema([
+ TextInput::make('block_name')
+ ->label(__('filament-constructor::block-settings.name')),
+ TextInput::make('block_anchor')
+ ->label(__('filament-constructor::block-settings.anchor'))
+ ->live(debounce: 500)
+ ->afterStateUpdated(fn (?string $state, Set $set) => $set('block_anchor', static::slugifyAnchor($state)))
+ ->dehydrateStateUsing(fn (?string $state) => static::slugifyAnchor($state))
+ ->rules(['regex:/^([a-z0-9]+(?:-[a-z0-9]+)*)?$/']),
+ ]),
+ Grid::make()
+ ->schema([
+ DateTimePicker::make('starts_at')
+ ->label(__('filament-constructor::block-settings.starts_at'))
+ ->before('ends_at'),
+ DateTimePicker::make('ends_at')
+ ->label(__('filament-constructor::block-settings.ends_at'))
+ ->after('starts_at'),
+ ]),
+ ])
+ ->collapsible()
+ ->collapsed();
+ }
}
diff --git a/src/FilamentConstructorServiceProvider.php b/src/FilamentConstructorServiceProvider.php
index 32b1b57..7c07030 100644
--- a/src/FilamentConstructorServiceProvider.php
+++ b/src/FilamentConstructorServiceProvider.php
@@ -15,6 +15,7 @@ class FilamentConstructorServiceProvider extends PackageServiceProvider
public function configurePackage(Package $package): void
{
$package->name(static::$name)
+ ->hasTranslations()
->hasCommands($this->getCommands())
->hasInstallCommand(function (InstallCommand $command) {
$command
diff --git a/tests/Feature/ConstructorBlockRendererTest.php b/tests/Feature/ConstructorBlockRendererTest.php
index 0b75162..621fc10 100644
--- a/tests/Feature/ConstructorBlockRendererTest.php
+++ b/tests/Feature/ConstructorBlockRendererTest.php
@@ -29,3 +29,67 @@
expect($renderer->render([]))->toBe('');
});
+
+it('renders an active block', function () {
+ $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]);
+
+ $html = $renderer->render([
+ (object) ['type' => 'test_block', 'data' => ['heading' => 'Visible', 'is_active' => true]],
+ ]);
+
+ expect($html)->toContain('Visible');
+});
+
+it('renders a block that has no is_active key (backward compatible)', function () {
+ $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]);
+
+ $html = $renderer->render([
+ (object) ['type' => 'test_block', 'data' => ['heading' => 'Legacy']],
+ ]);
+
+ expect($html)->toContain('Legacy');
+});
+
+it('hides an inactive block', function () {
+ $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]);
+
+ $html = $renderer->render([
+ (object) ['type' => 'test_block', 'data' => ['heading' => 'Hidden', 'is_active' => false]],
+ ]);
+
+ expect($html)->not->toContain('Hidden');
+});
+
+it('hides a block before its start time and shows it at the boundary', function () {
+ $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]);
+ $block = (object) ['type' => 'test_block', 'data' => ['heading' => 'Scheduled', 'starts_at' => '2026-07-10 12:00:00']];
+
+ $this->travelTo('2026-07-10 11:59:59');
+ expect($renderer->render([$block]))->not->toContain('Scheduled');
+
+ $this->travelTo('2026-07-10 12:00:00');
+ expect($renderer->render([$block]))->toContain('Scheduled');
+});
+
+it('hides a block after its end time and shows it at the boundary', function () {
+ $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]);
+ $block = (object) ['type' => 'test_block', 'data' => ['heading' => 'Expiring', 'ends_at' => '2026-07-10 12:00:00']];
+
+ $this->travelTo('2026-07-10 12:00:00');
+ expect($renderer->render([$block]))->toContain('Expiring');
+
+ $this->travelTo('2026-07-10 12:00:01');
+ expect($renderer->render([$block]))->not->toContain('Expiring');
+});
+
+it('renders a block inside its window and with empty dates', function () {
+ $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]);
+
+ $this->travelTo('2026-07-10 12:00:00');
+
+ $windowed = (object) ['type' => 'test_block', 'data' => ['heading' => 'InWindow', 'starts_at' => '2026-07-10 00:00:00', 'ends_at' => '2026-07-10 23:59:59']];
+ $unbounded = (object) ['type' => 'test_block', 'data' => ['heading' => 'Unbounded', 'starts_at' => null, 'ends_at' => null]];
+
+ expect($renderer->render([$windowed]))->toContain('InWindow')
+ ->and($renderer->render([$unbounded]))->toContain('Unbounded');
+});
diff --git a/tests/Feature/ConstructorTest.php b/tests/Feature/ConstructorTest.php
index 6897c0b..1d45f7d 100644
--- a/tests/Feature/ConstructorTest.php
+++ b/tests/Feature/ConstructorTest.php
@@ -5,6 +5,8 @@
use CubeAgency\FilamentConstructor\Tests\Fixtures\Blocks\TestBlock;
use Filament\Forms\Components\Builder;
use Filament\Forms\Components\Builder\Block;
+use Filament\Forms\Components\Field;
+use Filament\Schemas\Schema;
/**
* @return array
@@ -22,6 +24,56 @@ function constructorBlockNames(Constructor $constructor): array
return array_map(fn (Block $block) => $block->getName(), constructorBlocks($constructor));
}
+/**
+ * Recursively collect the names of every field within a component's schema.
+ *
+ * @return array
+ */
+function blockFieldNames($component): array
+{
+ $names = [];
+
+ $children = $component->getDefaultChildComponents();
+
+ if ($children instanceof Schema) {
+ $children = $children->getComponents();
+ }
+
+ foreach ($children as $child) {
+ if ($child instanceof Field) {
+ $names[] = $child->getName();
+ }
+
+ $names = array_merge($names, blockFieldNames($child));
+ }
+
+ return $names;
+}
+
+/**
+ * Find a field by name anywhere within a component's schema.
+ */
+function findBlockField($component, string $name): ?Field
+{
+ $children = $component->getDefaultChildComponents();
+
+ if ($children instanceof Schema) {
+ $children = $children->getComponents();
+ }
+
+ foreach ($children as $child) {
+ if ($child instanceof Field && $child->getName() === $name) {
+ return $child;
+ }
+
+ if ($found = findBlockField($child, $name)) {
+ return $found;
+ }
+ }
+
+ return null;
+}
+
it('is a filament builder', function () {
config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]);
@@ -54,3 +106,65 @@ function constructorBlockNames(Constructor $constructor): array
->and($block->getMaxItems())->toBe(3)
->and($block->getColumns('lg'))->toBe(2);
});
+
+it('adds block name and anchor fields to every block', function () {
+ config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]);
+
+ $block = constructorBlocks(Constructor::make('content'))[0];
+
+ expect(blockFieldNames($block))
+ ->toContain('heading') // the renderer's own field is preserved
+ ->toContain('block_name')
+ ->toContain('block_anchor');
+});
+
+it('uses the block name as the label and falls back to the title', function () {
+ config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]);
+
+ $block = constructorBlocks(Constructor::make('content'))[0];
+
+ expect($block->getLabel(null))->toBe('Test Block') // block picker
+ ->and($block->getLabel([]))->toBe('Test Block') // new, unnamed block
+ ->and($block->getLabel(['block_name' => '']))->toBe('Test Block')
+ ->and($block->getLabel(['block_name' => 'Hero header']))->toBe('Hero header');
+});
+
+it('slugifies the block anchor and nullifies empty values', function () {
+ expect(Constructor::slugifyAnchor('Hello World'))->toBe('hello-world')
+ ->and(Constructor::slugifyAnchor('Already-Slugged'))->toBe('already-slugged')
+ ->and(Constructor::slugifyAnchor(''))->toBeNull()
+ ->and(Constructor::slugifyAnchor(null))->toBeNull();
+});
+
+it('registers the block settings translations', function () {
+ expect(__('filament-constructor::block-settings.title'))->toBe('Block settings')
+ ->and(__('filament-constructor::block-settings.name'))->toBe('Name')
+ ->and(__('filament-constructor::block-settings.anchor'))->toBe('Anchor');
+});
+
+it('adds the visibility fields to every block', function () {
+ config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]);
+
+ $block = constructorBlocks(Constructor::make('content'))[0];
+
+ expect(blockFieldNames($block))
+ ->toContain('is_active')
+ ->toContain('starts_at')
+ ->toContain('ends_at');
+});
+
+it('defaults is_active to true', function () {
+ config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]);
+
+ $block = constructorBlocks(Constructor::make('content'))[0];
+ $isActive = findBlockField($block, 'is_active');
+
+ expect($isActive)->not->toBeNull()
+ ->and($isActive->getDefaultState())->toBeTrue();
+});
+
+it('registers the visibility field translations', function () {
+ expect(__('filament-constructor::block-settings.is_active'))->toBe('Active')
+ ->and(__('filament-constructor::block-settings.starts_at'))->toBe('Starts at')
+ ->and(__('filament-constructor::block-settings.ends_at'))->toBe('Ends at');
+});