lucacermenati / nova-global-filters
Session-backed global filters for Laravel Nova 5 dashboards: define filters in PHP, render them with one generic card, and make metrics respond to them.
Package info
github.com/lucacermenati/nova-global-filters
pkg:composer/lucacermenati/nova-global-filters
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0|^13.0
- laravel/nova: ^5.0
Requires (Dev)
- laravel/nova-devtool: ^1.7
- laravel/pint: ^1.20
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Session-backed global filters for Laravel Nova 5 dashboards. Define a filter in PHP, drop its card on a dashboard, and make any Value, Trend, Partition or Progress metric respond to it. The value is stored in the session, so it survives navigation, and applying it refreshes the metrics in place without reloading the page.
Ships with a DateRangeFilter (optional start and end date) and a SectionHeader card
to group metrics under a title.
Looking for a working example? The nova-playground repository is a small Nova app that uses this package end to end (see Example application).
Requirements
- PHP 8.2+
- Laravel 11, 12 or 13
- Laravel Nova 5
Installation
composer require lucacermenati/nova-global-filters
Optionally publish the config or the translations:
php artisan vendor:publish --tag=nova-global-filters-config php artisan vendor:publish --tag=nova-global-filters-lang
Quick start
Register the filters once, typically in NovaServiceProvider::boot():
use LucaCermenati\NovaGlobalFilters\Facades\NovaGlobalFilters; use LucaCermenati\NovaGlobalFilters\Filters\DateRangeFilter; NovaGlobalFilters::register([ DateRangeFilter::make(), ]);
Put the card on a dashboard:
use LucaCermenati\NovaGlobalFilters\Cards\GlobalFilterCard; use LucaCermenati\NovaGlobalFilters\Cards\SectionHeader; public function cards(): array { return [ GlobalFilterCard::make('date_range'), SectionHeader::make('Users'), (new TotalUsers)->width('1/2'), (new NewUsersPerDay)->width('1/2'), ]; }
Make a metric respond to it:
use Laravel\Nova\Metrics\Value; use LucaCermenati\NovaGlobalFilters\Metrics\RespondsToGlobalFilters; class TotalUsers extends Value { use RespondsToGlobalFilters; public function globalFilters(): array { return ['date_range']; } public function calculate(NovaRequest $request) { return $this->count($request, $this->applyGlobalFilters($request, User::query())); } public function ranges(): array { return []; // the global filter is the only time window } }
applyGlobalFilters() returns an Eloquent builder with the constraints of every declared
filter that currently has a value. Hand it to any Nova aggregate (count, sum,
countByDays, Partition count, Progress count, …).
Defining filters
Subclassing
use Illuminate\Contracts\Database\Eloquent\Builder; use LucaCermenati\NovaGlobalFilters\Filters\GlobalFilter; use LucaCermenati\NovaGlobalFilters\Inputs\SelectInput; class CountryFilter extends GlobalFilter { public function inputs(): array { return [ SelectInput::make('country', 'Country')->options(['NL' => 'Netherlands', 'IT' => 'Italy']), ]; } public function defaultOptions(): array { return ['column' => 'country']; } public function apply(Builder $query, mixed $value, array $options = []): void { $query->where($options['column'], $value['country']); } }
The key defaults to the snake-cased class name without the Filter suffix (country).
Override key() and name() to change that.
Fluent, without a class
GlobalFilter::make('country', 'Country') ->withInputs([SelectInput::make('country')->options(['NL' => 'Netherlands', 'IT' => 'Italy'])]) ->withDefaultOptions(['column' => 'country']) ->applyUsing(fn (Builder $query, array $value, array $options) => $query->where($options['column'], $value['country']));
Inputs
| Input | Produces | Rules |
|---|---|---|
DateInput::make('since') |
'Y-m-d' or null |
date_format:Y-m-d, optional min()/max() |
DateRangeInput::make('period') |
['start' => ?, 'end' => ?] |
end must be on or after start |
SelectInput::make('status')->options([...]) |
one of the option keys or null | in: the keys; anyLabel() names the empty option |
TextInput::make('q') |
string or null | string, max:255 |
BooleanInput::make('only_active') |
true or null |
unchecked means "not filtering" |
Every input accepts label(), help(), placeholder(), default() and extra
rules(...). Posted values are validated against these rules before they are stored.
Hooks on GlobalFilter
| Method | Default | Override when |
|---|---|---|
parse(array $raw): mixed |
the raw array without empty entries | you want a value object (see DateRangeFilter) |
isEmpty(mixed $value): bool |
blank($value) |
your value object knows when it is inactive |
serialize(mixed $value): array |
(array) $value |
the value object must become the form array again |
summary(mixed $value): ?string |
Label: value, … |
you want a nicer "active filter" text |
defaultOptions(): array |
[] |
metrics may override options such as the column |
Using filters in metrics
Declare what a metric responds to on the class:
public function globalFilters(): array { return [ 'country', // default options 'date_range' => ['column' => 'closed_at'], // per-metric override ]; }
or on the instance, from the dashboard definition:
(new ClosedTickets)->respondsTo('date_range', ['column' => 'closed_at']),
Raw queries
When a metric cannot go through a Nova aggregate, read the parsed value directly:
public function calculate(NovaRequest $request) { $query = Ticket::whereNotNull('closed_at'); if ($range = $this->globalFilterValue($request, 'date_range')) { // DateRange|null $query->when($range->start, fn ($q) => $q->where('created_at', '>=', $range->startOfDay())) ->when($range->end, fn ($q) => $q->where('created_at', '<=', $range->endOfDay())); } return $this->result((float) $query->selectRaw('AVG(TIMESTAMPDIFF(SECOND, created_at, closed_at)) as s')->value('s')); }
You can also apply the filter's own logic on any builder:
NovaGlobalFilters::filter('date_range')->apply($query, $range, $this->globalFilterOptions('date_range'));
Value metrics without ranges
A Nova Value metric with an empty ranges() receives no range from the frontend and
Nova then aggregates the last day only. applyGlobalFilters() detects this and asks
Nova for all time instead, so the global filters are the only window. Metrics that keep
their ranges() get both: Nova's range and the global filters intersect.
Trend metrics always apply Nova's own range window; a global date filter only narrows it.
Cached metrics
The trait extends Nova's metric cache key with the current filter values, so metrics
using cacheFor() refresh when a filter changes. If you override getCacheKey()
yourself, append $this->globalFilterCacheKey($request) to it.
Scopes
By default every card and metric shares one value per filter. Give a card and its metrics a scope to isolate a dashboard:
GlobalFilterCard::make('date_range')->scope('sales'), (new Revenue)->globalFilterScope('sales'),
Reading values elsewhere
NovaGlobalFilters::value('date_range'); // parsed value or null NovaGlobalFilters::rawValue('date_range'); // the stored form array or null NovaGlobalFilters::summary('date_range'); // "10 Jan 2026 → 20 Jan 2026" NovaGlobalFilters::set('country', ['country' => 'NL']); NovaGlobalFilters::clear('country');
HTTP API
The card talks to these routes (Nova's api_middleware, so the user must be allowed to
view Nova):
GET /nova-vendor/nova-global-filters/filters/{filter}
POST /nova-vendor/nova-global-filters/filters/{filter} { ...inputs, scope? }
DELETE /nova-vendor/nova-global-filters/filters/{filter} { scope? }
Each returns { filter, scope, value, summary, hasValue }. Validation errors come back
as a normal 422 response and are shown under the input.
Config
return [ 'session_prefix' => 'nova-global-filters', // session key: {prefix}.{scope}.{filter} 'default_scope' => 'default', 'middleware' => null, // null = config('nova.api_middleware') ];
Limitations
- Filters must be registered with
NovaGlobalFilters::register()before the write endpoints can resolve them. Passing a filter instance toGlobalFilterCard::make()registers it too, but only while the dashboard is being rendered. - No per-filter
canSeeyet: anyone who can view Nova can set any registered filter. - A
BooleanInputcannot express "false": unchecked means "do not filter". Use aSelectInputfor a yes/no/any choice.
Development
composer install composer test composer lint npm install npm run dev # or: npm run prod
The compiled assets in dist/ are committed; rebuild them after changing the Vue files.
Example application
The nova-playground repository is a
small Laravel Nova application that uses this package end to end: it registers a
DateRangeFilter and a fluent select filter, puts the cards on the dashboard and has
Value, Trend and Progress metrics responding to them. Use it as a reference for wiring
the package into your own project.
Changelog
See CHANGELOG.md.
License
MIT, see LICENSE.