Grid Card layout plugin screenshot
Dark mode ready
Multilingual support
Supports v5.x

Grid Card layout by Mustafa Khaled

Community

Replace any Filament resource's default list table with a responsive card/grid layout. Define how each record card looks using structured sections, a closure, or a custom Blade view — while keeping all of Filament's search, filters, sorting, pagination, bulk actions, and record actions working out of the box. No need to rewrite your resource. The same `table()` definition powers both the grid and the standard table.

Tags: Tables Panels
Supported versions:
5.x 4.x
Mustafa Khaled avatar Author: Mustafa Khaled

Package health

Automated checks of this plugin's Composer package

88 / 100
Security 85
Maintenance 88
Ecosystem 100
15 checks
  • Skipped: GitHub Actions pinned to SHA
  • Skipped: GitLab CI includes pinned to SHA
  • Passed: Open security advisories
  • Passed: Dependabot PR responsiveness — No open Dependabot PRs.
  • Skipped: Renovate MR responsiveness
  • Skipped: Dependabot or Renovate configured
  • Skipped: Dependency update cooldown configured
  • Failed: Provides a security policy — View details on Plumb
  • Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
  • Warning: Commit and release recency — Low activity: last commit 181 days ago; last release 181 days ago.
  • Passed: composer.lock not committed by library — composer.lock is absent from the released dist archive.
  • Passed: Dist archive is lean
  • Passed: Current Laravel version supported — Package dependencies resolve together with current Laravel 13.0.
  • Passed: Current PHP version supported — Constraint ^8.2 supports current PHP 8.5.
  • Skipped: Current Symfony version supported
Third-party plugin. This is built by the community, not the Filament team. Filament does not review, endorse, or vet the security of plugins outside the filament/ namespace. Review the source and install at your own risk. Found malware or an unresolved security issue the author won't address? Report it .
Powered by Plumb Last scanned 2 weeks ago

Documentation

Replace any Filament resource's default list table with a responsive card/grid layout. Define how each record card looks using structured sections, a closure, or a custom Blade view — while keeping all of Filament's search, filters, sorting, pagination, bulk actions, and record actions working out of the box.

No need to rewrite your resource. The same table() definition powers both the grid and the standard table.

#Requirements

  • PHP 8.4+
  • Laravel 13+
  • Filament 4+

#Features

  • Responsive CSS grid — configurable columns per Tailwind breakpoint (default, sm, md, lg, xl, 2xl)
  • Three card rendering modes — structured sections, closure-based, or custom Blade view
  • Full table infrastructure reuse — search, filters, sorting, pagination, bulk selection, record actions, header actions, empty state all work without extra setup
  • Configurable pagination — default records per page and page options at the page, plugin, or config level
  • Bulk selection — checkboxes on each card, works with existing bulk actions
  • Record actions — view, edit, delete, and custom actions render on each card
  • Click-to-navigate — cards link to view/edit pages using the resource's existing record URL logic
  • Badge support — render status badges with color on each card
  • Performance optimized — content-visibility: auto skips rendering of offscreen cards, lazy-loaded images
  • Dark mode support
  • Plugin-level defaults — set grid columns, gap, and pagination globally for all grid list pages
  • Three-level configuration cascade — page overrides plugin overrides config file

#Installation

composer require wezlo/filament-grid-list

Optionally register the plugin in your Panel Provider for global defaults:

use Wezlo\FilamentGridList\FilamentGridListPlugin;

->plugins([
    FilamentGridListPlugin::make()
        ->gridColumns(['default' => 1, 'sm' => 2, 'lg' => 3])
        ->gap(4)
        ->recordsPerPage(12)
        ->recordsPerPageOptions([12, 24, 48, 96]),
])

Optionally publish the config:

php artisan vendor:publish --tag=filament-grid-list-config

#Theme Source (Tailwind v4)

The package's Blade views use Tailwind utility classes. For Tailwind to detect them during your app's build, add the package's views as a @source in your Filament custom theme CSS file (usually resources/css/filament/admin/theme.css):

@import '../../../../vendor/filament/filament/resources/css/theme.css';

@source '../../../../vendor/wezlo/filament-grid-list/resources/views/**/*';

@custom-variant dark (&:where(.dark, .dark *));

If you don't have a custom theme yet, create one:

php artisan make:filament-theme

Then rebuild assets:

npm run build

Without this step, some Tailwind utilities used in the grid view may be missing from the compiled CSS.

#Quick Start

Add the HasGridList trait to your resource's ListRecords page and implement the gridList() method:

use Filament\Resources\Pages\ListRecords;
use Wezlo\FilamentGridList\Concerns\HasGridList;
use Wezlo\FilamentGridList\GridListConfiguration;

class ListProducts extends ListRecords
{
    use HasGridList;

    protected static string $resource = ProductResource::class;

    public function gridList(GridListConfiguration $config): GridListConfiguration
    {
        return $config
            ->gridColumns(['default' => 1, 'sm' => 2, 'lg' => 4])
            ->header(fn ($record) => $record->name)
            ->content(fn ($record) => Str::limit($record->description, 100))
            ->footer(fn ($record) => '$' . number_format($record->price, 2));
    }
}

That's it. The list page now renders as a card grid. Your existing table() definition continues to power filters, search, actions, and pagination.

#Card Rendering Modes

#Mode 1: Structured Sections

Build cards from discrete sections. Each receives the Eloquent record and returns a string or HtmlString. All sections are optional — use any combination.

public function gridList(GridListConfiguration $config): GridListConfiguration
{
    return $config
        ->image(fn ($record) => $record->thumbnail_url)
        ->header(fn ($record) => $record->name)
        ->badges(fn ($record) => [
            ['label' => $record->status->getLabel(), 'color' => $record->status->getColor()],
            ['label' => $record->category->name, 'color' => 'info'],
        ])
        ->content(fn ($record) => $record->short_description)
        ->footer(fn ($record) => '$' . number_format($record->price, 2));
}
Section Closure Signature Description
image(Closure) fn (Model $record): ?string Hero image URL at the top of the card
header(Closure) fn (Model $record): ?string Card title text
badges(Closure) fn (Model $record): array Array of badge definitions (see below)
content(Closure) fn (Model $record): string|HtmlString|null Card body text or HTML
footer(Closure) fn (Model $record): ?string Bottom section (price, date, metadata)

#Badge Format

Badges can be either arrays or renderable Blade components:

// Array format (recommended)
['label' => 'Active', 'color' => 'success']

// Any renderable (Blade component, HtmlString, etc.)
view('components.my-badge', ['status' => $record->status])

Available badge colors: primary, secondary, success, danger, warning, info, gray.

#Mode 2: Closure-based

Return raw HTML for full control over the card body:

use Illuminate\Support\HtmlString;

$config->describeUsing(fn ($record) => new HtmlString("
    <div class='p-4'>
        <h3 class='font-semibold'>{$record->name}</h3>
        <p class='text-sm text-gray-500'>{$record->description}</p>
        <span class='text-lg font-bold'>\${$record->price}</span>
    </div>
"));

#Mode 3: Custom Blade View

Point to your own Blade view for maximum flexibility:

$config->cardView('products.grid-card');

The view receives three variables:

Variable Type Description
$record Model The Eloquent record
$recordKey string The record's primary key
$recordUrl ?string URL the card links to (null if no link)
{{-- resources/views/products/grid-card.blade.php --}}
<div class="p-4">
    <img src="{{ $record->image_url }}" class="w-full h-48 object-cover rounded-lg" />
    <h3 class="mt-3 font-bold text-gray-900 dark:text-white">{{ $record->name }}</h3>
    <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $record->description }}</p>
    <div class="mt-3 flex items-center justify-between">
        <span class="text-lg font-semibold">${{ number_format($record->price, 2) }}</span>
        <span class="text-xs text-gray-400">{{ $record->created_at->diffForHumans() }}</span>
    </div>
</div>

#Rendering Priority

When multiple modes are configured, the first match wins:

  1. cardView() (custom Blade view)
  2. describeUsing() (closure)
  3. Structured sections (header(), content(), etc.)
  4. Fallback: displays the record's title via getTableRecordTitle()

#Configuration Reference

#Grid Layout

$config->gridColumns(['default' => 1, 'sm' => 2, 'md' => 3, 'lg' => 4])
       ->gap(6);  // Tailwind spacing unit (6 = 1.5rem)

Breakpoint keys follow Tailwind: default, sm, md, lg, xl, 2xl. The value is the number of columns at that breakpoint.

#Pagination

Set pagination on the grid config (overrides the table's defaults):

$config->recordsPerPage(24)
       ->recordsPerPageOptions([12, 24, 48, 96]);

Or configure it on your table() method as usual — the grid respects it:

public function table(Table $table): Table
{
    return $table
        ->columns([...])
        ->defaultPaginationPageOption(12)
        ->paginationPageOptions([12, 24, 48, 96]);
}

All Filament pagination modes (Default, Simple, Cursor) work.

Note: The 'all' page option is excluded from defaults to prevent page hangs on large datasets. Cards use content-visibility: auto for offscreen rendering optimization, but loading thousands of DOM nodes can still be slow. If you need it, opt in explicitly: ->recordsPerPageOptions([12, 24, 48, 'all']).

#Bulk Selection

Enabled by default when the table has bulk actions. Disable per-page with:

$config->selectable(false);

#Record Click URL

By default, cards link to the view/edit page using the resource's existing record URL logic (same as clicking a table row). Override per-page with:

$config->recordUrl(fn ($record) => route('products.show', $record));

#Plugin Configuration

Register the plugin in your Panel Provider to set defaults for all grid list pages in that panel:

use Wezlo\FilamentGridList\FilamentGridListPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        ->plugins([
            FilamentGridListPlugin::make()
                ->gridColumns(['default' => 1, 'sm' => 2, 'md' => 3, 'lg' => 4])
                ->gap(4)
                ->recordsPerPage(12)
                ->recordsPerPageOptions([12, 24, 48, 96]),
        ]);
}
Method Type Default Description
gridColumns(array) array<string, int> ['default' => 1, 'sm' => 2, 'md' => 3, 'lg' => 4] Responsive grid columns
gap(int) int 4 Tailwind spacing unit for card gap
recordsPerPage(int) int 12 Default records per page
recordsPerPageOptions(array) array<int|string> [12, 24, 48, 96] Per-page dropdown options

#Configuration Cascade

Each setting resolves through a three-level cascade:

  1. Page-level — values set in gridList() on the ListRecords page (highest priority)
  2. Plugin-level — values set on FilamentGridListPlugin in the Panel Provider
  3. Config file — values in config/filament-grid-list.php (lowest priority)

Page-level always wins. If not set, falls through to plugin, then config.

#Default Config File

// config/filament-grid-list.php
return [
    'grid_columns' => [
        'default' => 1,
        'sm' => 2,
        'md' => 3,
        'lg' => 4,
    ],
    'gap' => 4,
    'records_per_page' => 12,
    'records_per_page_options' => [12, 24, 48, 96],
];

#Full Example

use App\Enums\ProductStatus;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Str;
use Wezlo\FilamentGridList\Concerns\HasGridList;
use Wezlo\FilamentGridList\GridListConfiguration;

class ListProducts extends ListRecords
{
    use HasGridList;

    protected static string $resource = ProductResource::class;

    public function gridList(GridListConfiguration $config): GridListConfiguration
    {
        return $config
            ->gridColumns(['default' => 1, 'sm' => 2, 'md' => 3, 'xl' => 4])
            ->gap(6)
            ->recordsPerPage(24)
            ->recordsPerPageOptions([12, 24, 48])
            ->image(fn ($record) => $record->thumbnail_url)
            ->header(fn ($record) => $record->name)
            ->badges(fn ($record) => [
                ['label' => $record->status->getLabel(), 'color' => $record->status->getColor()],
                ['label' => $record->category->name, 'color' => 'info'],
            ])
            ->content(fn ($record) => Str::limit($record->description, 120))
            ->footer(fn ($record) => '$' . number_format($record->price, 2))
            ->selectable()
            ->recordUrl(fn ($record) => ProductResource::getUrl('view', ['record' => $record]));
    }
}

The resource's table() method stays unchanged — columns, filters, search, actions, and bulk actions all carry over to the grid view automatically.

#How It Works

  • The HasGridList trait overrides content() on the ListRecords page to render a custom Blade view instead of the default EmbeddedTable
  • The trait overrides makeTable() to apply pagination config from GridListConfiguration
  • Records come from $this->getTableRecords(), which handles the full filtered/sorted/paginated query pipeline from Filament's InteractsWithTable trait
  • The view initializes Filament's filamentTable() Alpine component for selection state management
  • Search binds to wire:model.live.debounce on the existing tableSearch Livewire property
  • Filters render using the table's filter trigger action and $this->getTableFiltersForm()
  • Bulk selection uses toggleSelectedRecord() / isRecordSelected() from the filamentTable Alpine component — same API as the standard table
  • Record actions are cloned per-record using $action->getClone() (same pattern as the standard table view)
  • Pagination renders via <x-filament::pagination> with the paginator from getTableRecords()
  • Cards use content-visibility: auto CSS so the browser skips layout/paint for offscreen cards

#Performance

  • content-visibility: auto — offscreen cards skip layout and paint, keeping the page responsive even with many records
  • Lazy-loaded images — card images use loading="lazy" so only visible images are fetched
  • No 'all' by default — pagination defaults to [12, 24, 48, 96] to prevent DOM overload. Opt in to 'all' explicitly if needed
  • Deferred loading support — respects Filament's wire:init="loadTable" for deferred table loading

#CSS Classes

All card elements use fi-grid-list-* prefixed classes for targeted styling:

Class Element
fi-grid-list Root container
fi-grid-list-content Grid container
fi-grid-list-card Individual card
fi-grid-list-card-clickable Card with a link
fi-grid-list-card-checkbox Selection checkbox wrapper
fi-grid-list-card-body Card content wrapper
fi-grid-list-card-image Image container
fi-grid-list-card-img Image element
fi-grid-list-card-content Text content area
fi-grid-list-card-title Header/title text
fi-grid-list-card-badges Badges container
fi-grid-list-card-description Content/description text
fi-grid-list-card-footer Footer area
fi-grid-list-card-actions Record actions bar

Override any of these in your theme CSS to customize the card appearance.

#License

MIT

The author

Mustafa Khaled avatar Author: Mustafa Khaled

15 Year Laravel Developer

Plugins
11
Stars
52

From the same author

Modal Notifications plugin thumbnail

Modal Notifications

Render any Filament notification as a blocking modal by chaining one method: ->asModal(). Multiple modal notifications fired in the same request are queued one at a time — the user dismisses one and the next slides in, no stacking.

Mustafa Khaled avatar Author: Mustafa Khaled
88 / 100 package health score out of 100
4 stars
Tag: Developer Tool Tag: Panels
Dark mode ready Multilingual support
Free
Get it now
Workspace Tabs plugin thumbnail

Workspace Tabs

Browser-like tabs for Filament panels. Open multiple pages in tabs without losing context, drag to reorder, pin frequently accessed pages, and right-click for quick actions.

Mustafa Khaled avatar Author: Mustafa Khaled
79 / 100 package health score out of 100
6 stars
Tag: Panels Tag: Kit More tags: +1
Dark mode ready Multilingual support
Free
Get it now
Record Freezer plugin thumbnail

Record Freezer

Freeze individual Eloquent records against modification — finalised contracts, audited financial periods, legal holds

Mustafa Khaled avatar Author: Mustafa Khaled
88 / 100 package health score out of 100
4 stars
Tag: Panels Tag: Developer Tool More tags: +1
No dark mode support No multilingual support
Free
Get it now
Record Watcher plugin thumbnail

Record Watcher

Subscribe to individual Eloquent records and receive in-panel Filament notifications whenever they change — with the actor (who) and a field-level diff (what). Watches can carry conditions ("only if status changes", "only if amount > 10K"), can be paused, and live on a personal **My Watches** page scoped to the authenticated user. Every fan-out is also persisted to a permanent event log, so users can review the full change history even after dismissing notifications.

Mustafa Khaled avatar Author: Mustafa Khaled
88 / 100 package health score out of 100
4 stars
Tag: Action Tag: Developer Tool More tags: +2
Dark mode ready Multilingual support
Free
Get it now