harvirsidhu/filament-cards
Filament Cards turns any Filament page into a card-based navigation hub for settings hubs, cluster front pages, and resource dashboards. It auto-discovers authorized pages, supports grouping and layout, and feels native to Filament.
Install and wire up Tailwind first — this is the step people skip.
composer require harvirsidhu/filament-cards
Then in your Filament theme.css:
@source '../../../../vendor/harvirsidhu/filament-cards/resources/views';
npm run build
Without that @source line the cards render unstyled (Tailwind never scans the vendor views). Requirements: PHP 8.2+, Laravel 11+, Filament v4 or v5.
Where to look first, in order:
Harvirsidhu\FilamentCards\Filament\Pages\CardsPage — the base class you extend; getCards() is the only method you must implement.CardItem and CardGroup — the two primitives you'll spend 95% of your time with.discoverClusterCards() / discoverResourceCards() — the auto-discovery helpers, which are the real reason to use this over hand-rolling a Blade grid.First use case — a 15-line standalone page:
class ControlPanel extends CardsPage
{
protected static ?string $navigationIcon = 'heroicon-o-squares-2x2';
protected static function getCards(): array
{
return [
CardItem::make(CompanySettings::class),
CardItem::make(BillingSettings::class),
];
}
}
Filament auto-registers the page. CardItem::make() accepts a Page class, a Resource class, or a URL string — for classes it pulls label, icon, badge, badgeColor, and url straight off the navigation properties, so you write almost nothing. Ship this, then graduate to discoverClusterCards() once your Cluster has more than three pages.
Cluster front page (the main event). Define the Cluster, then create a CardsPage inside it with $cluster set and $navigationSort = -1 so the hub sorts above the pages it links to:
class SettingsHub extends CardsPage
{
protected static ?string $cluster = Settings::class;
protected static ?int $navigationSort = -1;
protected static function getCards(): array
{
return static::discoverClusterCards();
}
}
Existing cluster pages need zero changes. Discovery walks every registered component, skips the CardsPage itself, calls canAccess(), checks visibility, then reads label/icon/URL/badge/description/group/sort. Breadcrumbs work automatically.
Instrument existing pages with static properties instead of editing the hub. This is the highest-leverage pattern — descriptions and grouping live on the page they describe:
class CompanySettings extends Page
{
public static ?string $navigationDescription = 'Manage company name, address, and branding.';
public static ?string $filamentCardsGroup = 'Business Settings';
}
New pages added to the Cluster appear on the hub with no hub edit. That's the property that keeps the hub from rotting.
Mix discovered and manual cards. Spread discovery into a normal array and append hand-built groups — external docs, status links, anything not a Filament page:
protected static function getCards(): array
{
return [
...static::discoverClusterCards(),
CardGroup::make('External Links')
->schema([
CardItem::make('https://docs.example.com')
->label('Documentation')
->icon('heroicon-o-book-open')
->openUrlInNewTab(),
]),
];
}
Order in the array is render order, so put the manual group last.
Resource hub. When a Resource has many custom pages, set $resource and use discoverResourceCards() — same hooks apply:
class UserSettingsHub extends CardsPage
{
protected static string $resource = UserResource::class;
protected static function getCards(): array
{
return static::discoverResourceCards();
}
}
Authorization and progressive disclosure. Discovery honors canAccess(), so you get tenant- and policy-aware cards for free. For finer control use closures, evaluated at render time:
CardItem::make(BillingSettings::class)
->visible(fn () => auth()->user()->can('manage-billing'));
CardItem::make(DangerZone::class)
->disabled(fn () => ! auth()->user()->isAdmin());
Prefer disabled() over hidden() for admin-only areas: showing a greyed-out card communicates "this exists, ask for access", while hiding it makes the feature invisible and generates support tickets.
Layout control. Use columnSpan() with a responsive map rather than fixed integers, and let important cards breathe:
CardItem::make(CompanySettings::class)->columnSpan(['default' => 1, 'md' => 2, 'lg' => 3]);
CardItem::make(NotificationPrefs::class)->columnSpanFull();
Search, wired for real users. Enable $searchable = true on the page, then add aliases and jargon to the cards — they're filtered on but never displayed. This is what makes a 20-card hub usable:
CardItem::make(BillingSettings::class)
->searchKeywords(['invoices', 'payments', 'subscriptions', 'finance']);
Central exclude lists for the "internal" stuff. Cheaper than annotating every page:
protected static array $excludedClusterComponents = [AuditLogsResource::class, InternalToolsPage::class];
protected static array $excludedResourcePages = [UserResource\Pages\DangerZone::class];
Use this for cross-cutting lists; use showInFilamentCards() on the page itself when the page knows it shouldn't be advertised.
Dynamic registration (from a service provider) exists for modular/package apps where pages aren't known at build time — reach for it only when the static getCards() array can't express the set. Optional plugin registration is available but not required; a CardsPage subclass alone is enough.
@source line is mandatory. If cards appear unstyled or with missing spacing/grid classes, 9 times out of 10 you skipped it or forgot npm run build after upgrading. Re-run the build after any package update.showInFilamentCards falls back to shouldRegisterNavigation(). Counterintuitive both ways: a page hidden from the sidebar will also vanish from the hub unless you explicitly return true, and any page you don't want advertised needs an explicit false. Audit new clusters with this in mind.$navigationGroup. Your sidebar taxonomy leaks into the card grid by default. If the hub should be organized differently, set getFilamentCardsGroup() on the pages — don't fight it in getCards().$navigationSort = -1 on the hub. Discovery skips the CardsPage itself, but without a low sort it can land after the pages it's supposed to be the front door for.visible(), hidden(), disabled(), label(), and searchKeywords() all accept closures evaluated at render time — fine for auth()->user(), dangerous if you put a query in one and render 30 cards.getNavigationBadge(), and that method often does a count(). On a hub with many resources, add ->badge(null) or override on the discovered cards if you notice slow page loads.canAccess(), visible(), or exclude lists. Set expectations with users accordingly, and lean on searchKeywords() for synonyms.CardItem::make(SomePage::class) deserves a ->visible() guard. Auto-discovery is documented to call canAccess(); for hand-written cards, don't assume the same and pair with an explicit policy check. It's one line and removes a class of accidental information leaks.protected static ?string $cluster, protected static string $resource) — PHP 8.2+ only, so check your CI matrix.composer.json, read the CHANGELOG on every bump, and keep a regression check on your hub page. If you need heavy visual customization, publish/vendor the views rather than overriding from outside.canAccess() return true, is showInFilamentCards / shouldRegisterNavigation true, is it in an exclude array, and does the CardsPage's own getCards() actually call discoverClusterCards()?CardsPage, override getCards(), use CardGroup for structure, use the exclude arrays for subtraction, and use dynamic registration for runtime addition. Prefer these over reaching into the package internals.How can I help you explore Laravel packages today?