mddev31/filament-dynamic-dashboard
End-user-configurable dashboards for Filament v4/5: drag, resize, and move widgets across named sections, with JSON layout presets, per-dashboard filters, typed widget settings, and optional Spatie role-based visibility.
Install in four commands, then check one thing before you write any code.
composer require mddev31/filament-dynamic-dashboard
php artisan vendor:publish --tag=filament-dynamic-dashboard-migrations
php artisan migrate
php artisan filament:assets
The fourth step is the one people skip. GridStack.js and the package bundle are published through Filament's own asset pipeline — there is no <script> tag to add — but if you deploy without running filament:assets on the target environment (or without committing public/js/public/css), you get a dashboard that renders widgets in a single static column with no drag handles and no console error explaining why. Treat it as a deploy step, not a one-off install step.
Optionally publish config and translations:
php artisan vendor:publish --tag=filament-dynamic-dashboard-config
php artisan vendor:publish --tag=filament-dynamic-dashboard-translations
Where to look first. Three files, in this order:
MDDev\DynamicDashboard\Pages\DynamicDashboard — the base page class. This is the whole surface area of page-level customisation (getDashboardFilters, getDefaultFilterSchema, resolveFilterDefaults, canEdit, canDisplay, showWidgetLoader).MDDev\DynamicDashboard\Contracts\DynamicWidget — nine methods, three for identity and six for size. Read the interface directly rather than the README table; it's the authoritative list.standard, split, trio, quad, sidebar, report, showcase, kpi) as JSON. These are the best documentation of the template schema — copy one and edit it rather than guessing at keys.First use case: convert one existing page, not your whole panel. The tempting move is to make every widget dynamic on day one. Don't. Pick your busiest operational dashboard, point a page subclass at it, and migrate two or three widgets:
namespace App\Filament\Pages;
use MDDev\DynamicDashboard\Pages\DynamicDashboard;
class OpsDashboard extends DynamicDashboard
{
protected static ?string $navigationIcon = 'heroicon-o-squares-2x2';
}
Then wrap a widget you already have:
class RevenueStats extends StatsOverviewWidget implements DynamicWidget
{
use InteractsWithPageFilters;
use HasEmptySettings;
use HasSizeDefaults;
public static function getWidgetLabel(): string
{
return 'Revenue Stats';
}
protected function getStats(): array
{
$country = $this->pageFilters['country'] ?? null;
// ...
}
}
Register it on the panel exactly as you would any other widget, visit the page, click Widget, choose Revenue Stats, and drag it. If drag and resize work, your asset pipeline is correct and everything after this is incremental. That first five minutes tells you whether the rest of the evaluation is worth your time.
The HasSizeDefaults + HasEmptySettings baseline, then override one axis at a time. Nearly every widget you write will look like this, and the pattern is deliberately additive — the traits fill in all six size methods, you override only what you actually care about:
class StatsBoardWidget extends StatsOverviewWidget implements DynamicWidget
{
use HasEmptySettings;
use HasSizeDefaults;
public static function getDynamicDashboardDefaultWidth(): int { return 6; }
public static function getDynamicDashboardMinWidth(): int { return 4; }
public static function getDynamicDashboardMaxHeight(): int { return 1; } // lock height
}
Locking an axis is done by setting min and max equal. Use this aggressively: ApexCharts and Chart.js widgets that get resized to a two-row sliver are the single most common source of "the dashboard looks broken" complaints, and locking height while leaving width free is usually what the user actually wanted anyway. Container resizes propagate into the chart libraries, so a free-width chart genuinely redraws — which is a feature, but only for charts that look good at every width.
Widget settings: declare public properties that match the cast keys exactly. The hydration contract is that settings are stored as JSON and mapped onto typed public properties via getSettingsCasts(). The form field names, the cast keys, and the property names must all be identical strings:
public ResultTypeEnum $resultType = ResultTypeEnum::GrossRevenue;
public ?int $limit = 5;
public static function getSettingsCasts(): array
{
return [
'resultType' => ResultTypeEnum::class,
'groupBy' => GroupingEnum::class,
'limit' => 'int',
];
}
Give every property a sensible default. On the very first render after a user adds the widget, no settings JSON exists yet, and the default on the property is what getOptions() sees. If you typed a non-nullable enum property and forgot the initialiser, you'll get an uninitialised-property error inside a Livewire commit with a stack trace that points at the chart library rather than at your widget.
Filters are per-dashboard, and the three filter hooks are a pipeline. getDashboardFilters() returns the form components in the filter bar. getDefaultFilterSchema() returns the (usually different) fields an admin uses to edit stored default values. resolveFilterDefaults() is the transform between the two — this is where you do things like turning a stored range: 'last_30' into a concrete [from, to] date pair. Keeping those responsibilities separate is what lets you offer a friendly "relative range" control in the defaults editor while the filter bar exposes actual dates.
Because InteractsWithPageFilters reads from the Livewire page, every widget that consumes filters must include the trait. A widget missing it silently receives no filter state — no error, just unfiltered numbers, which is the worst failure mode to debug on a dashboard.
Multiple dashboards per page with a picker, not multiple pages. The package models dashboards as rows scoped to a page, each with its own filter state and defaults. The idiomatic layout is one Filament page per audience (Ops, Finance, Marketing) and several dashboards inside each — "Finance / Monthly Close", "Finance / Board Pack". Personal dashboards are the exception: mark one is_personal and only its creator sees it, with the picker grouping it under a separator and a user icon.
Access control is two independent levels. Spatie role integration controls dashboard visibility; canDisplay() and canEdit() on the page class give you coarse-grained control over viewing and mutating. In practice: leave canEdit() returning true for internal tools, and return a role check on canDisplay() when a dashboard contains board-level numbers. Note canEdit() gates widget create/update/delete and dashboard management — there is no separate "can rearrange but not delete" toggle, so if you need that distinction you're overriding at the page level and accepting the blunt instrument.
Authoring a layout template is a JSON file with named sections, each with a column count, row span, and row height. The shipped presets cover most information architectures: kpi for a strip of stat cards over a grid, sidebar for a persistent narrow rail, report for a dense multi-section page. Start from the closest preset, copy it, change only the section definitions, and reference it by its new template_key. Resist inventing a section structure before you've seen how GridStack compacts widgets into it.
The getDynamicDashboard… prefix is not stylistic. Filament chart widgets define an instance method getMaxHeight(): ?string. PHP forbids a child class overriding an inherited instance method with a static one of the same name, so a widget implementing DynamicWidget without the prefix is a fatal error at class load. Don't "clean up" the verbose names in a refactor — you'll break every chart widget on the panel.
Implementing DynamicWidget without HasSizeDefaults means implementing all six size methods. The upgrade notes for v2 explicitly call this out as the fastest fix for v1 widgets. If you see a TypeError: Return value must be of type int, none returned on widget render after upgrading or hand-rolling the interface, this is it.
The v1 → v2 upgrade migration is deliberately irreversible. down() throws. Publish the stub, take a database snapshot, then migrate. The migration reparents every existing widget to section_slug = 'main' with x = 0 and GridStack compacting on first render — so on first load after upgrade, expect every dashboard to look like a single stacked column regardless of what it looked like before. That is expected, not corruption; users re-drag from there. Also budget for the communication: end users who customised layouts in v1 lose them.
Clearing caches is part of the upgrade path, not optional hygiene. view:clear and cache:clear after the upgrade. Filament caches the widget type registry that feeds the "Widget" modal; a stale cache means newly upgraded widgets don't appear in the picker and you conclude the upgrade failed.
Debugging a layout that won't persist or won't render: drop a level. The dashboards.template_key column tells you which JSON is being loaded — check that the key resolves to a file that exists, because a typo'd key doesn't always fail loudly. Then inspect dashboard_widgets directly: section_slug, x, y, w, h are plain columns. A widget that exists in the table but doesn't render is almost always a section_slug referencing a section the template no longer defines. For visual issues, the browser console on a GridStack page is informative — resize-handle problems, min/max constraint violations, and missing assets all surface there.
Extension points worth knowing, ranked by leverage:
resolveFilterDefaults() — the right place for any "relative date becomes absolute date" logic; don't push it into widgets.showWidgetLoader() — turn it off if your widgets are cheap. Leaving it on for lightweight stats widgets produces a distracting flicker on every filter change.Vendor risk is real and should shape your adoption. Ten stars, zero dependents, and a single-maintainer release cadence means: pin the exact version in composer.json ("mddev31/filament-dynamic-dashboard": "2.x.y", not ^2.0), read the changelog before every bump, and keep a working knowledge of the two tables it adds so you can hand-migrate data if you ever have to drop it. The package is a thin layer over GridStack plus Filament widgets, which is a good sign — the parts you'd most miss are the persistence plumbing and the settings-cast system, both of which are a few hundred lines and replaceable. The parts you'd miss least are the layout presets, which are just JSON.
Finally: the README is the documentation. There's no API site, so when the README is ambiguous — the getSettingsCasts() return shape, the exact template schema keys — read the interface and the shipped JSON presets in vendor/. In a package this size, the source is short enough that this is faster than searching for answers that don't exist yet.
How can I help you explore Laravel packages today?