bensondevs / fiddy
Filament Interactive and Descriptive Display — rich Image, Title, and Description components for Filament.
Requires
- php: ^8.2
- bensondevs/supercharged-enums: ^0.2.0
- filament/forms: ^5.0
- filament/infolists: ^5.0
- filament/tables: ^5.0
- illuminate/support: ^11.0|^12.0|^13.0
- squirephp/currencies-en: ^3.11
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pao: ^1.1
- laravel/pint: ^1.24
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0|^5.0
- spatie/laravel-medialibrary: ^11.23
Suggests
- spatie/laravel-medialibrary: Automatically use the default collection media as the select option image.
README
Filament Interactive and Descriptive Display — rich Image, Title, and Description components for Filament 5.
Fiddy layers presentation (avatar, title, description, hint, icons) on top of familiar Filament forms, infolists, tables, and filters — with one shared Content / Option model and a single presenter for everything.
Table of contents
Installation
Requires Filament ^5 and PHP ^8.2.
composer require bensondevs/fiddy
The service provider and assets register automatically.
If the app uses Laravel Boost, run php artisan boost:install (or boost:update) after requiring Fiddy. Boost discovers this package’s AI guidelines and the fiddy-development skill automatically — no extra registration.
Forms
Select
FiddySelect renders rich HTML options: image, title, description, hint, and icons — from parallel arrays, associative payloads, relationships, presenters, or FiddyComponentsPresentable. Agents using Laravel Boost should load the fiddy-development skill for the full Option / Content API.
use Bensondevs\Fiddy\Forms\Components\FiddySelect; use Bensondevs\Fiddy\Forms\Components\FiddySelect\Option; FiddySelect::make('assignee') ->options([1 => 'Alice', 2 => 'Bob']) ->descriptions([1 => 'Admin', 2 => 'Editor']) ->hints([1 => '555-0100']) ->images([1 => 'https://example.com/alice.jpg']) ->circularImages(); FiddySelect::make('user_id') ->relationship('user', 'name') ->presentUsing(UserPresenter::class); FiddySelect::make('user_id') ->relationship('user', 'name') ->presentOptionUsing( fn (User $record) => Option::make($record->getKey()) ->title($record->name) ->description($record->email) ->image($record->avatar_url) ->circularImage(), ); FiddySelect::make('status') ->enum(Status::class);
Without a presenter or presentable model, Fiddy guesses name|title, email|description, phone|hint, and Spatie Media Library default collection when available.
Presentation methods
Same priority as Presentation:
| Method | Input | Behavior |
|---|---|---|
presentOptionUsing(?Closure $callback) |
Closure returning Option |
Highest priority. See injections below. |
presentUsing(?string $presenter) |
Class-string of PresentsOption (typically a Presenter subclass) |
new $presenter($record)->toOption() |
| (none) | Model implements FiddyComponentsPresentable |
asOption() |
| (none) | Plain model / enum | OptionGuesser::from(...) or Filament enum contracts |
FiddySelect::make('user_id') ->relationship('user', 'name') ->presentOptionUsing(fn (User $record) => Option::make($record->getKey())->title($record->name)); FiddySelect::make('user_id') ->relationship('user', 'name') ->presentUsing(UserPresenter::class); FiddySelect::make('user_id')->relationship('user', 'name'); // presentable or guess
presentOptionUsing closure arguments
Fiddy injects the related / option model being presented:
| How | Parameter | Value |
|---|---|---|
| By name | $record |
Related model |
| By name | $related |
Same related model |
| By type | Model / concrete class |
Related model |
The closure must return an Option instance.
FiddySelect::make('user_id') ->relationship('user', 'name') ->presentOptionUsing( fn (User $related) => Option::make($related->getKey()) ->title($related->name) ->description($related->email), );
Static option helpers
Keyed like options(); each accepts array|Arrayable|Closure|null:
- Text:
descriptions(),hints() - Media:
images()/prefixImages(),suffixImages(),circularImages(bool|Closure) - Icons:
icons()/prefixIcons(),suffixIcons();titleIcons()/titlePrefixIcons()/titleSuffixIcons(); same fordescription*andhint*
Option fluent API
Build dropdown rows with Option::make($value) inside closures, toOption(), or asOption(). Icons accept string | BackedEnum | Htmlable | null.
| Method | Notes |
|---|---|
value($value) |
Option key |
title(?string) |
Primary label |
description(?string, $icon = null) |
Optional 2nd arg sets description icon |
hint(?string, $icon = null) |
Optional 2nd arg sets hint icon |
image / prefixImage / suffixImage |
Image URLs |
circularImage(bool = true) |
Round media |
disabled(bool|Closure = true) |
Disable the option |
tooltip(string|Htmlable|null) |
Hover text |
icon / prefixIcon / suffixIcon |
Leading / trailing icons |
titleIcon / titlePrefixIcon / titleSuffixIcon |
Beside title |
descriptionIcon / descriptionPrefixIcon / descriptionSuffixIcon |
Beside description |
hintIcon / hintPrefixIcon / hintSuffixIcon |
Beside hint |
Option::make($user->getKey()) ->title($user->name) ->description($user->email) ->hint($user->phone) ->image($user->avatar_url) ->circularImage() ->icon(Heroicon::OutlinedUser) ->titleSuffixIcon(Heroicon::OutlinedCheck) ->disabled(fn (): bool => ! $user->is_active) ->tooltip('Inactive users cannot be assigned');
Option has no aboveTitle / aboveDescription (those exist only on Content).
Numeric input
FiddyNumericInput masks thousand separators in the UI while keeping a clean numeric state. Use money() for currency-aware prefixes and separators, and spellAmount() for live spelled helper text.
use Bensondevs\Fiddy\Forms\Components\FiddyNumericInput; FiddyNumericInput::make('quantity'); FiddyNumericInput::make('amount') ->money(currency: 'USD') ->spellAmount(); // optional: ->spellAmount(locale: 'id')
Infolists
Entry
FiddyEntry shows a related BelongsTo / HasOne record as rich Content — same stack as FiddyColumn (presentUsing, presentContentUsing, presentable asEntryContent(), or attribute guessing). Closure injections and the full Content fluent API match the column docs below.
use Bensondevs\Fiddy\Infolists\Components\FiddyEntry; use Bensondevs\Fiddy\Support\Content; FiddyEntry::make('author') ->circularImages() ->presentUsing(AuthorPresenter::class); FiddyEntry::make('author') ->presentContentUsing( fn (Author $record) => Content::make() ->title($record->name) ->description($record->email), );
Image entry
FiddyImageEntry extends Filament’s image entry with click-to-preview (on by default) and soft rounded() corners alongside Filament’s circular().
use Bensondevs\Fiddy\Infolists\Components\FiddyImageEntry; FiddyImageEntry::make('header_image') ->rounded() ->imageHeight(80); FiddyImageEntry::make('avatar') ->circular() ->imageSize(40); FiddyImageEntry::make('photo') ->previewable(false); // disable lightbox; ->url() also wins over preview
Timestamp entry
FiddyTimestampEntry mirrors the FiddyTimestampColumn API (same trait methods, positions, and closure arguments).
use Bensondevs\Fiddy\Infolists\Components\FiddyTimestampEntry; use Illuminate\Database\Eloquent\Model; FiddyTimestampEntry::make('created_at') ->describeSubject() ->getSubjectUsing(fn (Model $record) => $record->creator) ->subjectPhoto() ->describeDiffForHuman();
Tables
Column
FiddyColumn extends Filament’s TextColumn and renders a related BelongsTo / HasOne model as rich HTML via Content. When no related model is found, the cell shows the placeholder -.
use Bensondevs\Fiddy\Tables\Columns\FiddyColumn; use Bensondevs\Fiddy\Support\Content; FiddyColumn::make('author') ->circularImages() ->searchable() ->sortable() ->filterable() ->presentUsing(AuthorPresenter::class); FiddyColumn::make('author') ->presentContentUsing( fn (Author $related) => Content::make() ->title($related->name, attribute: 'name') ->description($related->email, attribute: 'email') ->image($related->avatar_url), );
Related record resolution
The column state is the related model, not a scalar attribute. Resolution order:
relatedRecord(Model|Closure|null)— explicit override. AClosureis evaluated with Filament’s usual column injections (including the table row asrecord) and must return aModel(or null).- Otherwise, if the column name is a BelongsTo / HasOne relation on the row model, that relation’s result is used (
FiddyColumn::make('author')→$row->author). - Otherwise,
data_get($row, $name)is used (supports dotted paths likeauthor.managerwhen the value is aModel).
FiddyColumn::make('author') ->relatedRecord(fn (Post $record): ?Author => $record->coAuthor);
Presentation methods
Same resolution priority as Presentation:
| Method | Input | Behavior |
|---|---|---|
presentContentUsing(?Closure $callback) |
Closure returning Content |
Highest priority. See injections below. |
presentUsing(?string $presenter) |
Class-string of PresentsContent (typically a Presenter subclass) |
Instantiated as new $presenter($relatedModel); calls toContent(). |
| (none) | Related model implements FiddyComponentsPresentable |
Calls asColumnContent(). |
| (none) | Plain model | Content::guess($related) from conventional attributes. |
FiddyColumn::make('author')->presentUsing(AuthorPresenter::class); FiddyColumn::make('author')->presentContentUsing( function (Author $related): Content { return Content::make() ->title($related->name, 'name') ->description($related->email, 'email') ->hint($related->phone) ->image($related->avatar_url) ->circularImage(); }, );
presentContentUsing closure arguments
Fiddy injects the related model (not the table row) into the closure:
| How | Parameter | Value |
|---|---|---|
| By name | $record |
Related model |
| By name | $related |
Same related model |
| By type | Model |
Related model |
| By type | Concrete class (e.g. Author) |
Related model when it matches |
Also available via Filament’s normal column evaluation (Livewire component, column instance, etc.).
The closure must return a Content instance (or anything else is treated as empty / placeholder).
FiddyColumn::make('author')->presentContentUsing( fn (Author $related): Content => Content::make() ->title($related->name) ->description($related->email), );
Display helpers
| Method | Input | Behavior |
|---|---|---|
circularImages(bool|Closure $condition = true) |
Bool or Closure evaluated on the column | After content is resolved, forces Content::circularImage() when truthy. |
searchable(...) |
Same signature as Filament TextColumn::searchable |
When enabled without a custom $query, searches the related model with LIKE on name, title, email, and description. |
sortable(...) |
Same signature as Filament TextColumn::sortable |
When enabled without a custom $query, orders by related name via a relationship subquery. |
filterable(bool $condition = true) |
Bool | Shortcut for searchable(condition: true, isIndividual: true) (per-column search). |
FiddyColumn::make('author') ->circularImages() ->searchable() ->sortable() ->filterable();
BelongsTo / HasOne relations named like the column are eager-loaded automatically when the table query runs.
Note: searchable / sortable use those fixed fallback attribute lists on the related table. Passing $attribute into Content::title(..., $attribute) / description(...) records metadata on the Content object for its own helpers; it does not currently change which columns FiddyColumn::searchable() / sortable() query.
Content fluent API
Build cells with Content::make() inside closures, presenters (toContent()), or asColumnContent() / asEntryContent(). Icons accept string | BackedEnum | Htmlable | null (e.g. Heroicon enums or SVG HTML).
Text
| Method | Arguments | Notes |
|---|---|---|
title(?string $title, ?string $attribute = null) |
Display text; optional attribute name | $attribute stored as title attribute metadata |
description(?string $description, ?string $attribute = null) |
Secondary line; optional attribute | Same for description attribute |
hint(?string $hint) |
Tertiary line | |
aboveTitle(?string $text) |
Line above the title | Used heavily by timestamp columns |
aboveDescription(?string $text) |
Second above-title line |
Content::make() ->aboveTitle('Edited by Alice') ->aboveDescription('2 hours ago') ->title('Mar 1, 2026 3:00 PM') ->description('Alice') ->hint('Admin');
Media
| Method | Arguments | Notes |
|---|---|---|
image(?string $url) |
Image URL | Alias of prefixImage |
prefixImage(?string $url) |
Leading image | Prefered over prefix icon when set |
suffixImage(?string $url) |
Trailing image | Prefered over suffix icon when set |
circularImage(bool $circular = true) |
Bool | Rounds media |
Content::make() ->title('Alice') ->image('https://example.com/alice.jpg') ->suffixImage('https://example.com/badge.png') ->circularImage();
Icons (prefix / suffix pairs; short aliases set the prefix)
| Method | Alias of |
|---|---|
icon(...) |
prefixIcon(...) |
prefixIcon(...) / suffixIcon(...) |
Leading / trailing cell icon |
titleIcon(...) |
titlePrefixIcon(...) |
titlePrefixIcon(...) / titleSuffixIcon(...) |
Beside the title |
descriptionIcon(...) |
descriptionPrefixIcon(...) |
descriptionPrefixIcon(...) / descriptionSuffixIcon(...) |
Beside the description |
hintIcon(...) |
hintPrefixIcon(...) |
hintPrefixIcon(...) / hintSuffixIcon(...) |
Beside the hint |
aboveTitlePrefixIcon(...) / aboveTitleSuffixIcon(...) |
Beside above-title |
aboveDescriptionPrefixIcon(...) / aboveDescriptionSuffixIcon(...) |
Beside above-description |
use Filament\Support\Icons\Heroicon; Content::make() ->aboveTitle('Edited by Alice') ->aboveTitlePrefixIcon(Heroicon::OutlinedPencil) ->title('Mar 1, 2026 3:00 PM') ->titlePrefixIcon(Heroicon::OutlinedClock) ->description('Alice') ->descriptionPrefixIcon(Heroicon::OutlinedUser) ->hint('Admin') ->hintPrefixIcon(Heroicon::OutlinedShieldCheck) ->icon(Heroicon::OutlinedUser);
Content also provides ::guess(Model), ::fromEnum(UnitEnum), and ::resolveImageUrl(Model) (avatar_url, then Spatie Media Library default collection).
Timestamp column
FiddyTimestampColumn formats a datetime attribute as HTML Content: title is the translated datetime (M j, Y g:i A in the column timezone). Optional subject and relative-diff lines attach above or below that title. The same API is shared with FiddyTimestampEntry via FormatsTimestampContent.
use Bensondevs\Fiddy\Tables\Columns\FiddyTimestampColumn; use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Model; FiddyTimestampColumn::make('created_at') ->describeSubject() ->getSubjectUsing(fn (Model $record) => $record->creator) ->subjectPhoto() ->describeDiffForHuman() ->titlePrefixIcon(Heroicon::OutlinedClock);
Subject and relative difference
| Method | Input | Behavior |
|---|---|---|
describeSubject(string $position = 'bottom') |
'bottom' (default), or 'top' / 'above' |
Shows a subject name line. Defaults description prefix icon to Heroicon::OutlinedUser unless you set descriptionPrefixIcon yourself. |
describeDiffForHuman(string $position = 'bottom') |
Same position values | Shows a relative diffForHumans line (full datetime precision, not start-of-day). Default icon is calendar; if subject is off and you set descriptionPrefixIcon, that icon is reused for the diff. |
getSubjectUsing(?Closure $callback) |
Closure → ?Model |
Resolves who the subject is. Injected: named record (table/infolist row). If omitted, Fiddy soft-resolves an activity-log causer when available. |
getSubjectNameUsing(?Closure $callback) |
Closure → stringable | Overrides the displayed name. Injected: record, subject. Default: subject’s name-like attribute, or a localized “system” label. |
subjectPhoto(bool|Closure $condition = true) |
Bool or Closure | When truthy, shows a circular prefix image for the subject. |
getSubjectPhotoUsing(?Closure $callback) |
Closure → ?string URL |
Custom photo URL. Injected: subject, record. Default: Content::resolveImageUrl($subject). |
Position layout when both subject and diff are enabled:
- Both
bottom: subject → description line; diff → hint line - Both
top/above: subject → above-title; diff → above-description - Mixed positions follow the same slotting (first top line, second top line, first bottom line, second bottom line)
FiddyTimestampColumn::make('updated_at') ->describeSubject('top') ->describeDiffForHuman('bottom') ->getSubjectUsing(fn (Order $record): ?User => $record->editor) ->getSubjectNameUsing(fn (User $subject): string => $subject->display_name) ->subjectPhoto() ->getSubjectPhotoUsing(fn (User $subject): ?string => $subject->avatar_url);
Icon helpers on the timestamp
| Method | Applies to |
|---|---|
titlePrefixIcon(...) / titleSuffixIcon(...) |
The formatted datetime title |
descriptionPrefixIcon(...) / descriptionSuffixIcon(...) |
Subject (and sometimes diff) description line — setting prefix marks it “explicit” so defaults do not overwrite it |
Icon types: string | BackedEnum | Htmlable | null.
FiddyTimestampColumn::make('updated_at') ->describeSubject('top') ->describeDiffForHuman('bottom') ->getSubjectUsing(fn (Order $record): ?User => $record->editor) ->getSubjectNameUsing(fn (User $subject): string => $subject->display_name) ->subjectPhoto(fn (): bool => true) ->getSubjectPhotoUsing(fn (User $subject): ?string => $subject->avatar_url) ->titlePrefixIcon(Heroicon::OutlinedClock) ->descriptionPrefixIcon(Heroicon::OutlinedUser);
Filters
Select filter
FiddySelectFilter reuses the same rich option presentation as FiddySelect (presentUsing, presentOptionUsing, static helpers, enum, presentable asOption()). Dropdown wiring is forwarded to a nested FiddySelect.
Active chips are separate: presentable models use asFilterIndicator() → Indicator; otherwise the relationship title attribute (or enum label/icon via Indicator::fromEnum). Presenters do not affect chips.
use Bensondevs\Fiddy\Tables\Filters\FiddySelectFilter; use Bensondevs\Fiddy\Forms\Components\FiddySelect\Option; FiddySelectFilter::make('author_id') ->relationship('author', 'name') ->presentUsing(AuthorPresenter::class) ->searchable() ->preload(); FiddySelectFilter::make('status') ->enum(Status::class);
Indicator fluent API
| Method | Notes |
|---|---|
Indicator::make(?string $title) |
Chip label |
title(?string) |
Set / replace label |
icon(...) / prefixIcon(...) / suffixIcon(...) |
string|BackedEnum|Htmlable|null |
use Bensondevs\Fiddy\Tables\Filters\Indicator; use Filament\Support\Icons\Heroicon; // Typically returned from asFilterIndicator() on a presentable model: return Indicator::make($this->name) ->prefixIcon(Heroicon::OutlinedUser);
Presentation
Select, Column, Entry, and filter chips all render from the same Option / Content DTOs. You can fill those three ways: put presentation on the model (or enum), let Fiddy guess conventional attributes, or wire a separate Presenter class.
On the model
Implement FiddyComponentsPresentable. No presentUsing() is needed — Fiddy detects the interface automatically.
use Bensondevs\Fiddy\Models\Contracts\FiddyComponentsPresentable; use Bensondevs\Fiddy\Forms\Components\FiddySelect\Option; use Bensondevs\Fiddy\Support\Content; use Bensondevs\Fiddy\Tables\Filters\Indicator; class Author implements FiddyComponentsPresentable { public function asOption(): Option { /* select / filter options */ } public function asColumnContent(): Content { /* table cell */ } public function asEntryContent(): Content { /* infolist entry */ } public function asFilterIndicator(): Indicator { return Indicator::make($this->name); } }
| Method | Used by |
|---|---|
asOption() |
FiddySelect and filter dropdown options |
asColumnContent() |
FiddyColumn |
asEntryContent() |
FiddyEntry |
asFilterIndicator() |
Active FiddySelectFilter chips |
Wire components with a relationship (or attribute) only:
FiddySelect::make('author_id')->relationship('author', 'name'); FiddyColumn::make('author'); FiddyEntry::make('author'); FiddySelectFilter::make('author_id')->relationship('author', 'name');
Enums can implement the same contract for custom option / content / chip markup.
No presenter, no contract
If the related model has conventional attributes, Fiddy guesses without a presenter or presentable interface:
- Title:
nameortitle - Description:
emailordescription - Hint:
phoneorhint - Image:
avatar_url, or Spatie Media Library default collection when available
FiddySelect::make('user_id')->relationship('user', 'name'); FiddyColumn::make('user');
Enums without FiddyComponentsPresentable still work via Filament HasLabel / HasIcon / HasDescription and ->enum(Status::class).
Separate presenter
Prefer a presenter when presentation should stay off the Eloquent model, or when one class should drive Select, Column, Entry, and filter surfaces together.
php artisan fiddy:presenter User
use Bensondevs\Fiddy\Support\Presenter; use Bensondevs\Fiddy\Forms\Components\FiddySelect\Option; use Bensondevs\Fiddy\Support\Content; final class UserPresenter extends Presenter { public function toOption(): Option { return Option::make($this->record->getKey()) ->title($this->record->name) ->description($this->record->email); } public function toContent(): Content { return Content::make() ->title($this->record->name) ->description($this->record->email); } }
Declare the presenter on the model with HasFiddyPresenter — no hand-written asOption / asColumnContent / asEntryContent / asFilterIndicator, and no presentUsing() on each component:
use Bensondevs\Fiddy\Models\Concerns\HasFiddyPresenter; use Bensondevs\Fiddy\Models\Contracts\FiddyComponentsPresentable; class User extends Model implements FiddyComponentsPresentable { use HasFiddyPresenter; protected static string $fiddyPresenter = UserPresenter::class; }
FiddySelect::make('user_id')->relationship('user', 'name'); FiddyColumn::make('user'); FiddyEntry::make('user'); FiddySelectFilter::make('user_id')->relationship('user', 'name');
HasFiddyPresenter builds filter chips from the presenter’s toOption() title (and prefix icon when set). Override asFilterIndicator() on the model if chips need different markup.
Or wire the presenter per component with presentUsing() (highest priority after inline closures):
FiddySelect::make('user_id')->relationship('user', 'name')->presentUsing(UserPresenter::class); FiddyColumn::make('user')->presentUsing(UserPresenter::class); FiddyEntry::make('user')->presentUsing(UserPresenter::class); FiddySelectFilter::make('user_id')->relationship('user', 'name')->presentUsing(UserPresenter::class);
Notes:
- The presenter constructor takes a
Model. Enums useFiddyComponentsPresentableor Filament contracts instead ofpresentUsing/HasFiddyPresenter. - Component-level
presentUsing(...)alone does not affect filter chips. Chips useasFilterIndicator()(including the trait default) on presentable models, otherwise the relationship title attribute. presentUsing(...)on a component wins over a model that also implementsFiddyComponentsPresentable.
You can also pass an inline closure with presentOptionUsing(...) / presentContentUsing(...) when you only need one surface.
Resolution order
Highest priority first:
- Inline closure (
presentOptionUsing/presentContentUsing) ->presentUsing(SomePresenter::class)→toOption()/toContent()- Model or enum implements
FiddyComponentsPresentable - Attribute / enum-contract guessing
License
MIT








