Skip to main content
LinkPreviewField ← Back to Table of Contents

Summary

URL input with a live Open Graph / HTML meta preview card. The field uses the FlexTextInput pill shell for the input track and fetches page metadata through a server-side scrape endpoint (cached, rate-limitable). Preview cards support three layouts, optional URL prefix/suffix affixes, and configurable debounce / skeleton timing.
ClassBjanczak\FilamentFlexFields\Filament\Forms\Components\LinkPreviewField
State typestring|null — full URL (including configured prefix)
Model cast'article_url' => 'string' or leave uncast
FieldType(no dedicated FieldType mapping yet — use the class directly)
Playgroundlink-preview-field slug in Flex Fields playground

Basic usage

use Bjanczak\FilamentFlexFields\Filament\Forms\Components\LinkPreviewField;

LinkPreviewField::make('article_url')
    ->label('Article URL')
    ->placeholder('https://example.com/article')
    ->required();

LinkPreviewField::make('landing_page')
    ->label('Landing page')
    ->previewLayout('card')
    ->previewDebounce(750)
    ->default('https://laravel.com');
On a Filament resource:
use Filament\Forms\Form;
use Bjanczak\FilamentFlexFields\Filament\Forms\Components\LinkPreviewField;

public static function form(Form $form): Form
{
    return $form->schema([
        LinkPreviewField::make('external_url')
            ->label('Source URL')
            ->helperText('Paste a public URL — title, description, and image are fetched automatically.')
            ->columnSpanFull(),
    ]);
}

State format

ValueDescriptionExample
Valid URLTrimmed absolute URL stored on savehttps://laravel.com/docs
Emptynull after dehydratenull
Important: when prefix('https://') is set, the stored state is always the full URL (https://example.com/path). The visible input shows only the suffix (example.com/path) for readability. On blur, pasted full URLs are normalized back to suffix-only display.
LinkPreviewField::make('blog_path')
    ->prefix('https://')
    ->default('https://acme.test/blog/launch');

// Saved state: "https://acme.test/blog/launch"
// Input display: "acme.test/blog/launch"
Whitespace is trimmed on hydrate and dehydrate. Empty strings become null.

Preview layouts

Three card layouts via previewLayout():
LayoutModifier classBest for
horizontal (default)fff-link-preview__card--horizontalCompact rows — square thumb left, title + description + domain right (Twitter / X style)
verticalfff-link-preview__card--verticalNarrow columns — wide thumb on top, title + domain below
cardfff-link-preview__card--cardFull-width social cards — 16:9 thumb, title + domain
LinkPreviewField::make('share_url_horizontal')
    ->label('Share URL')
    ->previewLayout('horizontal');

LinkPreviewField::make('share_url_vertical')
    ->label('Share URL')
    ->previewLayout('vertical');

LinkPreviewField::make('share_url_card')
    ->label('Share URL')
    ->previewLayout('card')
    ->columnSpanFull();
The preview card is hidden when metadata is empty (no title, description, or image). A skeleton shimmer shows while fetching or while preloading the OG image. Errors render in a subtle role="alert" region below the card.

Configuration API

Each fluent method accepts a Closure for dynamic values (e.g. based on $get, $record, or $livewire).

variant(string|Closure $variant)

Visual style shared with FlexTextInput.
ValueDescription
primaryDefault filled pill shell
secondarySecondary surface tokens
softSofter background / border
flatMinimal chrome
ghostTransparent shell
LinkPreviewField::make('url')
    ->variant('soft');

LinkPreviewField::make('url')
    ->variant(fn (): string => 'secondary');

size(string|ControlSize|Closure $size)

Control height. See Control size. Default: md.
use Bjanczak\FilamentFlexFields\Enums\ControlSize;

LinkPreviewField::make('url')->size('sm');
LinkPreviewField::make('url')->size(ControlSize::Lg);

preview(bool|Closure $condition = true)

Enable or disable the preview card entirely. When false, the field behaves as a styled URL input only.
LinkPreviewField::make('internal_path')
    ->preview(false)
    ->rules(['url']);

previewDebounce(int|Closure $milliseconds)

Delay after typing before the client calls the scrape endpoint. Default: 500. Pass 0 for immediate fetch (use sparingly).
LinkPreviewField::make('url')->previewDebounce(750);

LinkPreviewField::make('url')->previewDebounce(0); // instant

previewMinUrlLength(int|Closure $length)

Minimum resolved URL character length before scraping starts. Default: 10. Enforced minimum: 4.
LinkPreviewField::make('url')->previewMinUrlLength(12);
Useful with prefixes — the check runs against the full resolved URL, not the visible suffix alone.

previewMinSkeletonMs(int|Closure $milliseconds)

Minimum skeleton display time on initial card reveal (SSR-prefilled URLs or first client fetch). Default: 500. Prevents flicker when metadata resolves instantly from cache.
LinkPreviewField::make('url')->previewMinSkeletonMs(300);
LinkPreviewField::make('url')->previewMinSkeletonMs(0); // no minimum

previewLayout('horizontal'|'vertical'|'card'|Closure $layout)

Card layout. Default: horizontal. Invalid values throw InvalidArgumentException.
LinkPreviewField::make('url')->previewLayout('card');

resolveInitialPreviewOnServer(bool|Closure $condition = true)

When true (default), the Blade view calls resolveInitialPreview() during SSR for prefilled URLs so the card can render immediately without waiting for Alpine. When false, initial preview is deferred to the client — useful on heavy forms to avoid blocking page render or duplicate scrapes.
LinkPreviewField::make('url')
    ->default('https://laravel.com')
    ->resolveInitialPreviewOnServer(false);
When true (default), the domain row is an <a> opening the URL in a new tab (rel="noopener noreferrer"). When false, the domain is plain text with the same icon styling (fff-link-preview__domain--text).
LinkPreviewField::make('url')->showVisitLink(false);

visitLabel(string|Closure $label)

Accessible label for the visit link (aria-label). Default: translated filament-flex-fields::default.link_preview.visit.
LinkPreviewField::make('url')->visitLabel('Open article in new tab');

visitIcon(string|BackedEnum|Htmlable|Closure|null $icon)

Icon beside the domain row. Default: GravityIcon::Paperclip.
use Bjanczak\FilamentFlexFields\Support\GravityIcon;

LinkPreviewField::make('url')->visitIcon(GravityIcon::Link);
LinkPreviewField::make('url')->visitIcon('heroicon-o-arrow-top-right-on-square');

prefix(string|Closure|null $label) / suffix(string|Closure|null $label)

Inline affix labels on the FlexTextInput track. Empty strings are treated as no affix.
LinkPreviewField::make('article_path')
    ->prefix('https://')
    ->suffix('.html');

LinkPreviewField::make('cdn_path')
    ->prefix(fn (): string => config('app.cdn_url').'/');

placeholder(string|Closure|null $placeholder)

Inherited from Filament HasPlaceholder. Default translation: filament-flex-fields::default.link_preview.placeholder.
LinkPreviewField::make('url')->placeholder('https://example.com');

readOnly(bool|Closure $condition = true) / disabled(bool|Closure $condition = true)

Inherited from Filament. Read-only still shows preview for the current URL; disabled blocks interaction and scraping triggers.
LinkPreviewField::make('canonical_url')
    ->default($record->canonical_url)
    ->readOnly();

LinkPreviewField::make('url')->disabled();

focusOutline(bool|Closure $condition = true)

Inherited from HasFieldFocusOutline. Default: false. When true, shows the shared --fff-field-focus-* ring on the input shell.
LinkPreviewField::make('url')->focusOutline();

Public helper methods

MethodReturnsDescription
getVariant()stringResolved variant
getSize()stringResolved size (sm, md, lg)
isPreviewEnabled()boolPreview card enabled
getPreviewDebounce()intDebounce ms (≥ 0)
getPreviewMinUrlLength()intMin URL length (≥ 4)
getPreviewMinSkeletonMs()intMin skeleton ms (≥ 0)
getPreviewLayout()stringhorizontal, vertical, or card
shouldResolveInitialPreviewOnServer()boolSSR preview resolution
shouldShowVisitLink()boolDomain row is a link
getVisitLabel()stringVisit link aria-label
getVisitIcon()string|BackedEnum|Htmlable|nullResolved visit icon
getPrefix() / getSuffix()string|nullResolved affixes
getScrapeUrl()stringRelative scrape route URL for Alpine
resolveInitialPreview(?string $url)array|nullServer-side metadata (title, description, image)
getAlpineConfiguration()arrayConfig passed to linkPreviewFieldFormComponent
getWrapperClasses()array<string, bool>Root CSS class map
resolveInitialPreview() returns null when preview is disabled, URL is empty, URL is not scrapable, or scrape returns no metadata.

Package configuration

Global scrape behaviour in config/filament-flex-fields.php:
KeyEnv variableDefaultDescription
link_preview.cache_ttl_secondsFLEX_FIELDS_LINK_PREVIEW_CACHE_TTL86400Server-side scrape cache TTL
link_preview.rate_limit_per_minuteFLEX_FIELDS_LINK_PREVIEW_RATE_LIMIT30Per-user scrape rate limit
link_preview.timeout_secondsFLEX_FIELDS_LINK_PREVIEW_TIMEOUT8HTTP timeout for remote pages
Publish config:
php artisan vendor:publish --tag=filament-flex-fields-config
Example .env:
FLEX_FIELDS_LINK_PREVIEW_CACHE_TTL=43200
FLEX_FIELDS_LINK_PREVIEW_RATE_LIMIT=30
FLEX_FIELDS_LINK_PREVIEW_TIMEOUT=10
The client also keeps an in-memory cache and in-flight deduplication (url-meta-scrape.js) so repeated keystrokes do not spam the server. Scrape endpoint (named route): filament-flex-fields.url-meta.scrape.

Validation

RuleDetail
Built-innullable, url
required()Standard Filament required validation
Hydrate / dehydrateTrims whitespace; empty → null
LinkPreviewField::make('website')
    ->required()
    ->rules(['url', 'max:2048']);

Model & database examples

// Migration
$table->string('article_url')->nullable();

// Model — no special cast required
protected $fillable = ['article_url'];

// Factory / seeder
'article_url' => 'https://laravel.com',
Editing an existing record with SSR preview:
LinkPreviewField::make('article_url')
    ->default(fn (?Article $record): ?string => $record?->article_url)
    ->resolveInitialPreviewOnServer(true);

use Filament\Schemas\Components\Grid;
use Bjanczak\FilamentFlexFields\Filament\Forms\Components\LinkPreviewField;

Grid::make(1)->schema([
    LinkPreviewField::make('cta_url')
        ->label('Call-to-action URL')
        ->previewLayout('card')
        ->visitLabel('Preview landing page')
        ->previewDebounce(600)
        ->columnSpanFull(),

    LinkPreviewField::make('social_proof_url')
        ->label('Social proof link')
        ->previewLayout('horizontal')
        ->variant('soft')
        ->size('sm'),
]);

Recipe: prefixed marketing domain

LinkPreviewField::make('campaign_path')
    ->label('Campaign page')
    ->prefix('https://go.acme.com/')
    ->placeholder('summer-sale')
    ->previewMinUrlLength(8)
    ->helperText('Enter the path only — https:// is added automatically.');

Recipe: read-only audit display

LinkPreviewField::make('submitted_url')
    ->label('Submitted URL')
    ->default(fn ($record) => $record->submitted_url)
    ->readOnly()
    ->showVisitLink(true)
    ->previewLayout('vertical');

Recipe: heavy admin form — defer SSR scrape

LinkPreviewField::make('reference_url')
    ->resolveInitialPreviewOnServer(false)
    ->previewDebounce(800)
    ->previewMinSkeletonMs(400);

Recipe: reactive live() — drive sibling fields from preview URL

use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;

LinkPreviewField::make('source_url')
    ->label('Source URL')
    ->live(debounce: 500)
    ->afterStateUpdated(function (?string $state, Set $set): void {
        if (blank($state)) {
            $set('source_domain', null);

            return;
        }

        $set('source_domain', parse_url($state, PHP_URL_HOST));
    })
    ->columnSpanFull();

TextInput::make('source_domain')
    ->label('Domain')
    ->disabled()
    ->dehydrated();
Use live() when other form fields should react to URL changes. Preview fetching still debounces independently via previewDebounce().

Recipe: affix-only internal paths (preview disabled)

For intranet or relative paths where server scraping is not useful — URL input only, no preview card:
LinkPreviewField::make('help_path')
    ->label('Help article path')
    ->prefix('https://docs.acme.test/')
    ->placeholder('getting-started/install')
    ->preview(false)
    ->rules(['required', 'max:255'])
    ->helperText('Enter the path after the docs host — full URL is stored on save.');
When users paste slow or rate-limited URLs, reduce churn and keep the domain as plain text:
LinkPreviewField::make('external_reference')
    ->label('External reference')
    ->previewDebounce(1000)
    ->previewMinSkeletonMs(600)
    ->showVisitLink(false)
    ->visitLabel('Open reference')
    ->helperText('If preview fails, the URL is still saved — check the address is public.');
Scrape failures render in fff-link-preview__error with role="alert"; they do not block form submission when nullable / url rules pass.

Accessibility

  • Preview card container uses aria-live="polite" for loading and reveal updates
  • When revealed, aria-label on the card reflects the scraped page title
  • Visit link uses visitLabel() as aria-label
  • Scrape errors use role="alert"
  • Input remains a standard Filament field with label / hint / error association

CSS classes

ClassRole
fff-link-preview-fieldFilament wrapper modifier
fff-link-preview-field--{sm|md|lg}Size modifier
fff-link-preview-field--{variant}Variant modifier
fff-link-preview-field--layout-{horizontal|vertical|card}Layout modifier on wrapper
fff-link-previewAlpine root (FlexTextInput shell)
fff-link-preview__cardPreview card container
fff-link-preview__card--{horizontal|vertical|card}Layout modifier
fff-link-preview__domain--textNon-link domain row when showVisitLink(false)
fff-link-preview__errorScrape error message
Shares FlexTextInput shell classes (fff-flex-text-input, fff-flex-text-input__shell, variant modifiers).

Assets

Lazy-loaded stylesheets (via FlexFieldAssets::stylesheetsFor('link-preview-field')):
  • flex-text-input
  • link-preview-field
Alpine component: link-preview-field (loaded with x-load). Uses wire:ignore on the Alpine root — prefer changing Livewire state or sibling fields over direct DOM manipulation in tests.

Implementation notes

  • Metadata is scraped server-side via UrlMetaScraper (Open Graph + fallback <title> / <meta name="description"> / <meta property="og:image">).
  • Non-scrapable URLs (invalid scheme, localhost, etc.) skip preview quietly.
  • Image preload runs client-side before revealing the card to avoid layout pop-in.
  • Invalid variant() or previewLayout() values throw InvalidArgumentException at render time.