Skip to content

Example: custom pricing section

A complete, production-shaped custom section from the demo host app: a pricing grid with a monthly/yearly toggle, repeatable plan blocks, and a featured-plan highlight. It exercises most of the section API surface — the settings DSL, a Block with nested settings, defaults(), live-preview bindings, and theme-token-driven styling — in one file pair:

  • app/Sections/PricingSection.php — the section class
  • resources/views/sections/pricing.blade.php — the Blade view

The section class

php
<?php

declare(strict_types=1);

namespace App\Sections;

use FilamentCraft\Sections\BladeSection;
use FilamentCraft\Sections\Block;
use FilamentCraft\Settings\Setting;
use FilamentCraft\Settings\Types\Checkbox;
use FilamentCraft\Settings\Types\Color;
use FilamentCraft\Settings\Types\ColorScheme;
use FilamentCraft\Settings\Types\Select;
use FilamentCraft\Settings\Types\Text;
use FilamentCraft\Settings\Types\Textarea;

final class PricingSection extends BladeSection
{
    protected static ?string $previewImage = 'images/section-previews/pricing.png';

    protected static string $view = 'sections.pricing';

    public static function slug(): string
    {
        return 'pricing';
    }

    public static function name(): string
    {
        return 'Pricing';
    }

    public static function icon(): string
    {
        return 'heroicon-o-banknotes';
    }

    public static function wrapper(): string
    {
        return 'section.demo-pricing';
    }

    /**
     * @return array<int, Setting>
     */
    public static function settings(): array
    {
        return [
            ColorScheme::make('scheme')->label('Color scheme'),
            Text::make('title')->label('Title')->default('Pricing that scales with you'),
            Textarea::make('subtitle')
                ->label('Subtitle')
                ->default('Pick the plan that fits today. Upgrade as you grow.'),
            Select::make('billing')
                ->label('Default billing cycle')
                ->options([
                    'monthly' => 'Monthly',
                    'yearly' => 'Yearly (save 20%)',
                ])
                ->default('monthly'),
            Text::make('monthly_label')->label('Monthly toggle label')->default('Monthly'),
            Text::make('yearly_label')->label('Yearly toggle label')->default('Yearly · save 20%'),
            Text::make('popular_label')->label('Featured badge label')->default('Most popular'),
            Text::make('billing_note')
                ->label('Billing note')
                ->default('Yearly plans are billed annually. Cancel anytime.'),
            Color::make('accent')->label('Accent color')->default('#6366f1'),
        ];
    }

    /**
     * @return array<int, Block>
     */
    public static function blocks(): array
    {
        return [
            Block::make('plan')
                ->name('Plan')
                ->settings([
                    Text::make('name')->label('Plan name')->default('Starter'),
                    Text::make('tagline')->label('Tagline')->default('Everything you need to launch.'),
                    Text::make('currency')->label('Currency symbol')->default('$'),
                    Text::make('price')->label('Monthly price')->default('19'),
                    Text::make('yearly_price')->label('Yearly price (per month)')->default('15'),
                    Text::make('period')->label('Period')->default('/mo'),
                    Textarea::make('features')
                        ->label('Features (one per line)')
                        ->default("Unlimited pages\nLive editor\nEmail support"),
                    Checkbox::make('featured')->label('Highlight as featured')->default(false),
                    Text::make('cta_label')->label('CTA label')->default('Start trial'),
                    Text::make('cta_url')->label('CTA link')->default('#'),
                ]),
        ];
    }

    /**
     * @return array{settings: array<string, mixed>, blocks: array<int, array<string, mixed>>}
     */
    public static function defaults(): array
    {
        return [
            'settings' => [
                'title' => 'Pricing that scales with you',
                'subtitle' => 'Pick the plan that fits today. Upgrade as you grow.',
                'billing' => 'monthly',
                'monthly_label' => 'Monthly',
                'yearly_label' => 'Yearly · save 20%',
                'popular_label' => 'Most popular',
                'billing_note' => 'Yearly plans are billed annually. Cancel anytime.',
                'accent' => '#6366f1',
            ],
            'blocks' => [],
        ];
    }
}

Anatomy

  • slug() — the stable identifier stored in template revisions. Renaming the class is safe; changing the slug orphans existing content, so treat it as permanent.
  • name() / icon() / $previewImage — what editors see in the section catalog.
  • wrapper() — the tag/selector wrapping the rendered output (default 'section'). section.demo-pricing adds a stable class hook for host CSS without touching the view.
  • $view — a plain Blade view in the host app's own resources/views. BladeSection is the right base class here because the section has no server-side state; the monthly/yearly toggle is a few lines of vanilla JS, so the live site stays Livewire-free.
  • settings() — section-level settings using the input DSL. ColorScheme::make('scheme') gives editors the standard per-section scheme picker.
  • blocks() — the repeatable unit. Each plan is a Block with its own nested settings, so editors add/reorder/remove plans in the sidebar rather than filling flat plan_one_name / plan_two_name slots. Chain ->limit(4) on the Block to cap how many plans an editor can add; the section-wide ceiling is the overridable maxBlocks() (default 16). Two more schema hooks this section leaves at their defaults: category() groups the section in the add-section catalog, and presets() ships one-click pre-filled variants.
  • defaults() — the content a fresh instance starts with. Blocks are intentionally empty here: the view renders an "Add a Plan block to get started" empty state, which nudges the editor toward the repeater instead of shipping placeholder plans.

The view

The view receives $section, whose settings is a value bag ($s->get('key', $fallback)) and whose blocks is the ordered list of plan blocks. Trimmed to the instructive parts — every line shown is verbatim from the demo:

blade
@php($s = $section->settings)
@php($scheme = $section->colorScheme())
@php($defaultBilling = $s->get('billing', 'monthly') === 'yearly' ? 'yearly' : 'monthly')
@php($rootId = 'demo-pricing-'.$section->id)
@php($activePill = 'background-color: var(--fc-color-surface); color: var(--fc-color-on-surface); box-shadow: 0 1px 2px rgba(0,0,0,0.08);')
<div id="{{ $rootId }}" class="demo-pricing px-6 py-20" data-billing="{{ $defaultBilling }}"
    {!! $scheme->attributes() !!}
    style="
        background-color: var(--fc-color-background);
        color: var(--fc-color-on-background);
        font-family: var(--font-default, ui-sans-serif, system-ui, sans-serif);
        font-size: var(--font-body-size, 1rem);
    ">
    <div class="mx-auto max-w-3xl text-center">
        <h2 {!! $section->liveUpdate('title') !!}
            class="text-3xl font-bold sm:text-4xl"
            style="font-family: var(--font-heading, var(--font-default, ui-sans-serif, system-ui, sans-serif)); color: var(--fc-color-on-background);">
            {{ $s->get('title') }}
        </h2>
        <p {!! $section->liveUpdate('subtitle') !!}
            class="mt-3 text-lg"
            style="color: color-mix(in oklab, var(--fc-color-on-background) 75%, transparent);">
            {{ $s->get('subtitle') }}
        </p>
        {{-- … monthly/yearly toggle pills (data-billing-target buttons) trimmed --}}
    </div>

    @if (count($section->blocks) > 0)
        <div class="mx-auto mt-12 grid max-w-6xl gap-6 md:grid-cols-2 lg:grid-cols-3">
            @foreach ($section->blocks as $block)
                @php($featured = (bool) ($block->settings->get('highlighted') ?? $block->settings->get('featured')))
                @php($features = preg_split('/\r?\n/', (string) $block->settings->get('features', '')) ?: [])
                @php($currency = trim((string) $block->settings->get('currency', '$')))
                @php($monthlyPrice = (string) $block->settings->get('price'))
                @php($yearlyPrice = (string) $block->settings->get('yearly_price', $monthlyPrice))
                @php($period = trim((string) $block->settings->get('period')))
                @php($numeric = is_numeric($monthlyPrice))
                @php($ctaUrl = (string) ($block->settings->get('url') ?: $block->settings->get('cta_url') ?: '#'))
                {{-- … card shell, featured badge, plan name/tagline trimmed --}}
                    <ul class="mt-6 space-y-2 text-sm">
                        @foreach ($features as $feature)
                            @if (trim((string) $feature) !== '')
                                <li class="flex gap-2">
                                    <span style="color: var(--fc-color-primary);">✓</span>
                                    <span style="color: var(--fc-color-on-surface);">{{ $feature }}</span>
                                </li>
                            @endif
                        @endforeach
                    </ul>
                {{-- … CTA button and card close trimmed --}}
            @endforeach
        </div>
    @else
        <p class="mt-12 text-center text-sm"
            style="color: color-mix(in oklab, var(--fc-color-on-background) 60%, transparent);">
            Add a Plan block to get started.
        </p>
    @endif
    {{-- … billing note and toggle <script> trimmed --}}
</div>

What to notice:

  • Theme tokens, never hard-coded colors. Every color is a --fc-* variable (--fc-color-background, --fc-color-surface, --fc-color-primary, …) and every typographic/shape choice reads a theme variable (--font-heading, --button-radius) with a sensible fallback. That's what makes one section render correctly under every theme and color scheme.
  • $scheme->attributes() applies the editor-picked per-section color scheme by swapping the --fc-color-* values on this subtree.
  • liveUpdate() bindings. {!! $section->liveUpdate('title') !!} (and $block->liveUpdate('name') on block fields) lets the editor patch text in place as you type instead of re-rendering the whole section. For attributes rather than text, chain ->attr() — the CTA link uses {!! $block->liveUpdate('cta_url')->attr('href') !!}. The builder also offers ->html() (rich-text values), ->style('property') (patch one CSS property), and ->toggleClass('class') (flip a class from a boolean setting).
  • The highlighted / url fallbacks. The block schema above declares featured and cta_url, yet the view also reads $block->settings->get('highlighted') and get('url'). That's demo-specific compatibility: this section's pricing slug shadows the built-in pricing section, whose blocks use those keys — so plans authored against the built-in keep rendering. If your slug is your own, read only your declared keys.
  • Derived values are computed defensively (preg_split on the features textarea, is_numeric on the price) because settings arrive as editor-typed strings.
  • The toggle script is idempotent. The trimmed <script> guards with root.dataset.fcBound before wiring click handlers, because the editor preview re-renders sections in place — without the guard, every morph would stack another listener.

Zero-config discovery

There is no registration step. The demo's config/filamentcraft.php leaves both hooks empty:

php
'sections' => [
    'paths' => [],
    'register' => [],
],

The conventional app/Sections/ directory is auto-scanned, so dropping PricingSection.php there is enough for it to appear in the catalog. paths and register exist for sections that live elsewhere (a domain module, a shared package).