Stop Explaining Your Form in a Placeholder: Filament v5's Callout Component

The filament callout component landed in v5.2. Replace the Placeholder-plus-HtmlString hack, pick between helperText and hint, and avoid the live() trap.

Steven Richardson
Steven Richardson
· 11 min read

Every non-trivial admin form eventually needs to say something that does not belong on a single field. "Changing the billing country resets the tax configuration." "This account is in trial — publishing now starts the subscription." Filament had no component for it, so we all wrote the same amber <div> inside a Text or a Placeholder and moved on. That is no longer necessary.

Check your Filament version first#

The Callout component shipped in Filament v5.2.0 and v4.7.0, both released on 4 February 2026 (PR #19189). It arrived in a minor release rather than a major, which is why most panels on v5 already have it and nobody has noticed. Confirm before you start.

composer show filament/filament | grep versions

If you are below v5.2 the class does not exist and you will get a "Class not found" at render time, not at boot. If you are still on v4 and want to move, I wrote up the step-by-step migration path from v4 to v5.

Replace the hand-rolled notice box with a Callout#

Here is the workaround everybody has shipped at least once — a prime Text component carrying an HtmlString full of hardcoded Tailwind classes. It ignores the panel's colour palette, has no dark-mode variant unless you write one, and ends up a slightly different shade of amber in every resource.

use Filament\Schemas\Components\Text;
use Illuminate\Support\HtmlString;

Text::make(new HtmlString(
    '<div class="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
        <strong>Heads up.</strong> Changing the billing country resets the tax configuration.
    </div>'
))
    ->columnSpanFull(),

The replacement is three lines and renders in the panel's own design language, dark mode included.

use Filament\Schemas\Components\Callout;

Callout::make('Changing the billing country resets tax')
    ->description('Existing VAT rates and exemptions on this account are cleared and recalculated on save.')
    ->warning()
    ->columnSpanFull(),

The heading is the make() argument. The body is description(). Everything else is a modifier.

Pick the right status variant#

Four variants set the icon, the icon colour and the background in one call: info(), warning(), danger() and success(). They are not decoration — Filament renders a visually-hidden severity prefix (Note:, Warning:, Error:, Success:) inside the heading so screen reader users hear the severity before the message, and marks the leading icon aria-hidden so it is not announced twice.

use Filament\Schemas\Components\Callout;

Callout::make('Payment successful')
    ->description('Your order has been confirmed and is being processed.')
    ->success()

Callout::make('Session expiring soon')
    ->description('Your session expires in 5 minutes. Save your work to avoid losing changes.')
    ->warning()

Callout::make('Connection failed')
    ->description('Unable to reach the payment provider. Check the API credentials.')
    ->danger()

If the filled background is too loud for a form that already has several sections, color(null) strips the background but keeps the coloured icon. iconSize(IconSize::Small) tones it down further, and icon() overrides the variant's default glyph.

use Filament\Schemas\Components\Callout;
use Filament\Support\Enums\IconSize;
use Filament\Support\Icons\Heroicon;

Callout::make('Scheduled maintenance')
    ->description('The system is unavailable on Sunday from 02:00 to 04:00 UTC.')
    ->warning()
    ->color(null)
    ->icon(Heroicon::OutlinedClock)
    ->iconSize(IconSize::Small)

Choose between helperText, hint, Text and Callout#

Filament already had three ways to attach words to a field before the filament callout component existed, and a fourth for arbitrary content. They are not interchangeable. The deciding question is scope: is this about one input, or about a decision the user is making?

API Scope Position Prominence Best for
helperText() One field Below the input Quiet, always visible Format rules — "Include the country code"
hint() One field Top-right of the label Terse, one line A link or a short status — "Forgot your password?"
hintIcon() One field Next to the hint Hover-only tooltip Trivia a user can live without seeing
Text prime Anywhere in the schema In the flow Body copy Read-only computed values, arbitrary markup
Callout A section or the whole form In the flow, full width Loud, semantic colour A consequence, a warning, a state explanation

The rule I use: if the sentence starts with "this field must…", it is helperText(). If it starts with "changing this will…" or "because this account is…", it is a Callout. Anything hidden behind a hover is not primary content, so hintIcon() never carries information the user needs to complete the form.

Make the callout conditional on form state#

A permanent callout is wallpaper. Users stop reading the box that is always there. The component earns its place when it appears only at the moment the guidance matters, which is what visible() and Filament's utility injection are for.

use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Callout;
use Filament\Schemas\Components\Utilities\Get;

Select::make('billing_country')
    ->options(['GB' => 'United Kingdom', 'DE' => 'Germany', 'US' => 'United States'])
    ->live(), // Without this, the callout below never re-evaluates.

Callout::make('This country requires a VAT number')
    ->description('EU customers must supply a valid VAT registration before the first invoice is issued.')
    ->warning()
    ->columnSpanFull()
    ->visible(fn (Get $get): bool => in_array($get('billing_country'), ['DE', 'FR', 'ES'], true)),

The same closure can inject ?Model $record instead, which is how you explain the state of the record being edited rather than the value of a field.

use Filament\Schemas\Components\Callout;
use Illuminate\Database\Eloquent\Model;

Callout::make('This account is still in trial')
    ->description('Publishing now ends the trial and starts the first billing period immediately.')
    ->info()
    ->columnSpanFull()
    ->visible(fn (?Model $record): bool => $record?->onTrial() ?? false),

And string $operation gives you a callout that only shows on create, or only on edit:

->visible(fn (string $operation): bool => $operation === 'edit')

Wire up the live() requirement on the watched field#

This is the single most common reason a conditional callout never appears, so it is worth stating on its own. Filament does not re-render the schema when a field's value changes unless that field is reactive. Miss ->live() on the Select above and the callout only materialises after the next round trip to the server — usually the save — which is exactly too late.

Select::make('billing_country')
    ->options($countries)
    ->live(),          // Re-renders on every change.

TextInput::make('vat_number')
    ->live(onBlur: true), // Re-renders when the field loses focus.

Use live(onBlur: true) for text inputs so you are not firing a request per keystroke. The same reactivity rules govern every dependent component in a schema — I covered the mechanics in more depth in dependent dropdowns with live() and reactive fields.

The temptation is to shove an <a> tag into the description. Do not. Callouts take real Filament actions in the footer, which means you get the panel's button styling, authorization, confirmation modals and everything else for free.

use Filament\Actions\Action;
use Filament\Schemas\Components\Callout;

Callout::make('Your trial ends in 3 days')
    ->description('Upgrade now to keep access to scheduled reports and the API.')
    ->warning()
    ->actions([
        Action::make('upgrade')
            ->label('Upgrade to Pro')
            ->url(route('billing.upgrade'))
            ->button(),
        Action::make('compare')
            ->label('Compare plans')
            ->url('https://example.com/pricing', shouldOpenInNewTab: true),
    ])

If you genuinely need formatted prose — a bulleted list, an inline link inside a sentence — use footer(), which accepts an array of schema components, and put a Text prime in it. That is the documented route to markup, and it keeps the escaping decision explicit.

use Filament\Schemas\Components\Callout;
use Filament\Schemas\Components\Text;

Callout::make('Import rules have changed')
    ->info()
    ->footer([
        Text::make(
            str('Rows are now matched on **SKU**, not on [product name](/docs/imports).')
                ->inlineMarkdown()
                ->toHtmlString(),
        ),
    ])

One hard rule: anything user-supplied that reaches an HtmlString must go through e() first. This renders markup by design, and an admin panel that echoes an attacker-controlled product name into a callout is a stored XSS with an audience of exactly the people who can do the most damage.

Text::make(new HtmlString(
    'Last import failed on row ' . e($record->failed_row_label) . '.'
))

Reuse the same callout in an infolist#

Callout is a schema component, not a form component. Filament v5's unified schema layer means the identical class, with the identical import, drops into an infolist or a custom schema page. No adaptation, no second implementation.

use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Callout;
use Filament\Schemas\Components\Section;

public function infolist(Schema $schema): Schema
{
    return $schema->components([
        Callout::make('This subscription is past due')
            ->description('Two payment attempts have failed. Access is revoked in 5 days.')
            ->danger()
            ->columnSpanFull()
            ->visible(fn (?Model $record): bool => $record?->subscription?->pastDue() ?? false),

        Section::make('Subscription')
            ->schema([
                TextEntry::make('plan.name'),
                TextEntry::make('renews_at')->dateTime(),
            ]),
    ]);
}

That is the whole argument for the schema unification in v5, demonstrated in one component. If you are building read-only screens, the same pattern slots straight into a read-only record view built with infolists.

Sidestep the gotchas#

A few things will cost you twenty minutes if nobody warns you.

The callout renders half-width inside a grid. Schema components inherit the parent's column layout, so a callout inside a two-column Grid or Section takes one column and looks broken next to an empty cell. columnSpanFull() is almost always what you want.

controls() is newer than the component. The original v5.2.0 release shipped controlActions() for the top-right corner. The more flexible controls() slot, which takes arbitrary schema components, arrived in v5.3.0 and v4.8.0 (PR #19263). If controls() throws a "method does not exist", you are on v5.2 and want controlActions().

A wizard step evaluates against state that may not exist yet. Put a conditional callout on step three and the closure still runs while the user is on step one, where $get('billing_country') is null. Null-guard every comparison rather than relying on loose equality. The same care applies anywhere you split a form across steps — see turning a create page into a multi-step wizard.

A custom theme can neutralise the danger signal. Variant colours resolve through the panel's palette. If your brand has remapped warning to a soft lilac, the callout is no longer legible as a warning and colour alone stops carrying meaning — keep the icon and write a heading that states the severity in words. Worth checking before you rely on it if you have white-labelled the panel.

Callouts are a plain <div>. That is correct for content present on page load. If you reveal one dynamically after an action completes and want assistive technology to announce it, opt into a live region explicitly.

Callout::make('Changes saved')
    ->success()
    ->extraAttributes(['role' => 'status']) // Use `alert` for urgent messages.

Do not use a callout where validation belongs. Validation is reactive, accessible, wired to the field and cleared automatically when the user fixes the problem. A permanent red callout restating a rule is noise, and the user learns to skip every callout on the screen — including the one that mattered.

Wrapping Up#

Add one callout per screen, tied to a decision the user is about to make, and make it conditional. If it is visible on every page load it is not guidance, it is furniture. Start with the destructive path in your busiest resource: the field that resets other data, or the state that changes what the save button actually does.

Then write a test that asserts it appears only under the condition you intended — testing Filament v5 resources with Pest covers asserting schema state — and if you are assembling dashboards rather than resource forms, the same component drops into a custom Filament v5 page hosting Livewire widgets.

FAQ#

How do I add a callout or notice box to a Filament form?

Import Filament\Schemas\Components\Callout and add Callout::make('Your heading')->description('The body text.') to the form schema, alongside your fields. Chain a status variant such as warning() or info() to get the icon and colour, and add columnSpanFull() so it spans the whole form rather than one grid column. It requires Filament v5.2.0 or v4.7.0 and above.

What is the difference between helperText, hint and a Callout in Filament?

helperText() and hint() both attach to a single field — helper text sits quietly below the input, a hint sits at the top-right of the label. A Callout is not attached to a field at all: it is a schema component scoped to a section or a whole form, rendered prominently with a semantic colour and icon. Use the field-level APIs for rules about one input, and a callout for a consequence or a state the user needs to understand before making a decision.

How do I show a Filament callout only when a field has a certain value?

Chain visible() with a closure that injects Get $get, for example ->visible(fn (Get $get): bool => $get('country') === 'DE'). The critical part is that the field being watched must be marked ->live(), otherwise Filament does not re-render the schema when its value changes and the callout only appears after the next server round trip. You can also inject ?Model $record to react to the record's state, or string $operation to show it only on create or edit.

Can I put a link or HTML inside a Filament Callout?

Yes, but use the supported slots rather than raw markup in the description. actions() takes an array of Filament actions, so Action::make('docs')->label('Read the guide')->url($url) gives you a properly styled link with authorization and modal support. For formatted prose, footer() accepts schema components, so a Text prime carrying an HtmlString works — and anything user-supplied inside that string must be passed through e() first.

Does the Callout component work in Filament infolists?

It does. Callout lives in Filament\Schemas\Components, not in the forms or infolists namespace, so the same class and the same import work in a form schema, an infolist schema and a custom schema page. That is the practical payoff of Filament v5's unified schema layer — one component definition covers all three contexts with no adaptation.

Which Filament version added the Callout component?

The Callout component shipped simultaneously in Filament v5.2.0 and v4.7.0, both released on 4 February 2026. The controls() slot was added later in v5.3.0 and v4.8.0. Check with composer show filament/filament; below those versions the class does not exist and the Placeholder or Text workaround with hardcoded classes is still the only option.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.