digital-creative/nova-slider-filter
A Laravel Nova filter that adds an interactive range slider to resource index screens, letting users filter records by numeric values quickly and intuitively. Easily configure the column, min/max values, and step size for a lightweight, native-feeling Nova filter.
The package is a single-purpose Nova filter that renders a range slider in Nova's filter panel and hands a numeric range back to your resource query. Last release was October 2023, so it targets Nova 4.x; check compatibility before dropping it into a Nova 5 app.
Install
composer require digital-creative/nova-slider-filter:^1.0.1
Pin at least 1.0.1. The 1.0.0 release shipped with a bug where the default option was not correctly applied on first load — the slider could render (and submit) an unintended starting range until the user touched it. 1.0.1 fixes that. If you are already running 1.0.0, this is a drop-in patch, but read the note on defaults in Gotchas and Tips before you upgrade blindly: filters that previously appeared unset on first load may now actually apply a range.
No asset publishing step is required — the compiled JS/CSS ships in dist/. If you ever fork and edit resources/js, you must run the package's npm run build yourself.
Where to look first
README.md on GitHub — authoritative list of options() keys (min, max, step, type/range, default, prefix, suffix, tooltips, etc.). The key names are the part most likely to trip you up; confirm them before writing production code.src/NovaSliderFilter.php — the base class you extend. Confirms the exact apply(Request, Builder, $value) signature and how the value is shaped for range vs. single mode.dist/js/filter.js — only if you need to know exactly what payload hits the backend (helpful when the value arrives as ['min' => …, 'max' => …] vs. a flat array), or to confirm the default-handling change from 1.0.1.Create your first filter
php artisan nova:filter PriceRangeFilter
use DigitalCreative\NovaSliderFilter\NovaSliderFilter;
use Illuminate\Http\Request;
use Laravel\Nova\Fields\Number;
class PriceRangeFilter extends NovaSliderFilter
{
public $name = 'Price';
public function options(Request $request): array
{
return [
'min' => 0,
'max' => 2000,
'step' => 25,
// 'default' => ['min' => 0, 'max' => 2000],
];
}
public function apply(Request $request, $query, $value)
{
if (empty($value) || ! isset($value['min'], $value['max'])) {
return $query;
}
return $query->whereBetween('price', [$value['min'], $value['max']]);
}
}
Register it in app/Nova/Resource.php (or the specific resource):
public function filters(Request $request)
{
return [new PriceRangeFilter];
}
First real use case: a numeric column you would otherwise filter with two number inputs (price, stock, age, rating, distance). The slider wins when the range is intuitive and you want a single drag gesture instead of typing two values.
whereBetween is the canonical pattern. Guard for empty selection first — Nova calls apply() even when the user has never touched the filter.
public function apply(Request $request, $query, $value)
{
if (! is_array($value) || ! isset($value['min'], $value['max'])) {
return $query;
}
return $query->whereBetween('price', [$value['min'], $value['max']]);
}
For "minimum rating" or "within the last N days", configure the slider as single-value ('type' => 'single' or equivalent in your version) and treat $value as a scalar:
public function apply(Request $request, $query, $value)
{
if ($value === null || $value === '') {
return $query;
}
return $query->where('rating', '>=', (int) $value);
}
Hard-coding max breaks the moment a $5,000 item is listed. Override the value at runtime:
public function options(Request $request): array
{
$bounds = Product::query()->selectRaw('MIN(price) mn, MAX(price) mx')->first();
return [
'min' => (int) floor($bounds->mn ?? 0),
'max' => (int) ceil($bounds->mx ?? 1000),
'step' => 10,
'prefix' => '$',
];
}
Cache this if the table is large — options() runs on every filter panel render.
If you combine dynamic bounds with a default, compute the default from the same bounds in one place, otherwise the fixed 1.0.1 behaviour will faithfully apply a default that no longer matches your data (see gotchas).
Since 1.0.1 the default is reliably applied on first load, so this pattern now does exactly what it says. Pair it with an apply() guard so an untouched filter is a no-op:
public function options(Request $request): array
{
return [
'min' => 0,
'max' => 5000,
'step' => 50,
'default' => ['min' => 0, 'max' => 5000],
];
}
public function apply(Request $request, $query, $value)
{
if (! is_array($value) || ! isset($value['min'], $value['max'])) {
return $query;
}
// Treat "full range" as no filter so we don't exclude NULL price rows.
if ($value['min'] <= 0 && $value['max'] >= 5000) {
return $query;
}
return $query->whereBetween('price', [$value['min'], $value['max']]);
}
This is the single most valuable pattern — it keeps the "clear filters" experience consistent and avoids accidentally hiding rows with NULL in the filtered column. Keep the numeric literals in options() and apply() in sync via a class constant so a future bounds change doesn't silently start filtering.
If you have half a dozen numeric ranges, subclass once:
abstract class NumericRangeFilter extends NovaSliderFilter
{
abstract protected function column(): string;
public function apply(Request $request, $query, $value)
{
if (! is_array($value) || ! isset($value['min'], $value['max'])) {
return $query;
}
return $query->whereBetween($this->column(), [$value['min'], $value['max']]);
}
}
class StockRangeFilter extends NumericRangeFilter
{
protected function column(): string { return 'stock'; }
public function options(Request $request): array { return ['min'=>0,'max'=>500,'step'=>5]; }
}
withMetaAnything not in options() (e.g. a display-friendly unit label, tooltip text, or currency symbol) can be pushed into the frontend:
public function __construct()
{
parent::__construct();
$this->withMeta(['unit' => 'kg']);
}
Only do this for static values — withMeta is applied at construction, not per-request, so DB-driven values belong in options().
The slider composes normally. A typical dashboard filter set:
public function filters(Request $request)
{
return [
new PriceRangeFilter, // slider
new CategoryFilter, // select
new InStockFilter, // boolean
];
}
All filter values land in the URL query string, so combinations are bookmarkable and survive reloads.
Because the value is stored as a query parameter, saved views work out of the box — the slider re-hydrates on load. With 1.0.1 the re-hydration is also correct on a cold first load, which is the case that used to be flaky. Still set a sensible default so the "no saved value" path is deterministic.
1.0.1 changes first-load behaviour — audit your defaults on upgrade. The fix means default is now honoured on the very first render/submit; on 1.0.0 it frequently wasn't. If any of your filters define a default that is not a full-range no-op (e.g. ['min' => 0, 'max' => 100] meaning "cheap items"), upgrading will start filtering index pages that previously showed everything. Test the index, lenses, and saved views after bumping the version.
apply() runs even with no selection. Nova invokes every registered filter against the index/lens query regardless of whether the user has touched it. Always early-return when $value is null, [], or '' — otherwise $value['min'] on a null will throw Trying to access array offset on null and break the index page. The 1.0.1 fix makes this guard more important, not less: a default now actually arrives, so your guard decides whether it filters.
Confirm the value shape once. Range mode returns ['min' => …, 'max' => …] in most builds, but versions have differed (some return [min, max]). Log it once:
public function apply(Request $request, $query, $value)
{
\Log::debug('slider value', ['v' => $value, 'type' => gettype($value)]);
// ...
}
Then write your guard against the actual shape.
Step must divide (max − min) cleanly. If max - min = 2000 and step = 30, the handles snap unpredictably at the ends and the tooltip can show 1980 when the user dragged to the top. Pick a step of 1, 5, 10, 25, 50, 100, 250, 500. This also matters for defaults: a default whose bounds don't align to step may be silently snapped, so verify the rendered value after upgrading to 1.0.1.
Floats and money. Sliders work best on integers. Store prices as cents in the DB and either format in options() or divide in apply():
return $query->whereBetween('price_cents', [$value['min'] * 100, $value['max'] * 100]);
Do not rely on step => 0.01 for clean currency selection — float drift shows up in tooltips.
NULL rows disappear. whereBetween excludes NULL columns. If the filtered column is nullable, decide explicitly whether empty values should count:
return $query->where(function ($q) use ($value) {
$q->whereBetween('price', [$value['min'], $value['max']])
->orWhereNull('price');
});
withMeta is construction-time. It runs once when the filter is instantiated, not per request. If your label or unit depends on the resource being viewed, drive it through options() instead.
Assets are pre-compiled. Editing src/ is enough for backend changes, but touching resources/js silently does nothing until you cd into the package and run npm install && npm run build (or swap to a Vite build if you're forking for Nova 5). Nova will serve the stale dist. Note that the default fix shipped in the dist bundle as well as src/, so if you have vendored or patched the package in vendor/, re-apply your patch after composer update.
Reset behaviour depends on default. Nova's "Reset Filters" clears the query string; whether the slider snaps back to 0…max or to the left edge depends on the default option. Set it explicitly if a cleared state looks wrong in your UI — and re-check it after 1.0.1, since the reset path and first-load path now share the same defaulting logic.
Nova 4 only. The release line targets Nova 4; 1.0.1 is a patch with no Nova 5 work. If you're on Nova 5, either pin the filter behind a feature check, test in staging first, or fork and adjust the Vue component against the Nova 5 build. Don't assume binary compatibility.
Relationship columns need a join. You cannot whereBetween on customers.lifetime_value from the orders resource without a join. Do the join in apply(), or precompute the column on the resource's base table:
return $query->join('customers', 'orders.customer_id', '=', 'customers.id')
->whereBetween('customers.lifetime_value', [$value['min'], $value['max']])
->select('orders.*');
orWhere traps. If you build a compound predicate in apply(), wrap it in a closure. Nova composes filter predicates sequentially; a bare orWhere at the top level will leak past the filter's own conditions and pollute downstream filters.
Unique $name per instance. Two slider filters on the same resource need distinct $name or Nova will collapse them into one entry in the filter dropdown. Use the constructor:
public function __construct(string $name = 'Weight')
{
parent::__construct();
$this->name = $name;
}
URLs leak the range. The selected range appears in the query string (e.g. ?price=0-2000). That's usually a feature for shareable links, but treat dashboards as semi-public if the range reflects sensitive business thresholds. Defaults now appearing in URLs on first load (post-1.0.1) makes this slightly more visible.
Metrics and cards. Filter values are propagated into metric requests, so a slider on the resource also refines your Value/Trend cards — provided the metric respects the resource's applyFilters(). Verify with a card after wiring the filter up; a range-only metric that ignores the filter is usually a problem on the card side, not the filter. Re-verify after upgrading, because a default that now applies on first load will also change the numbers your cards show before any interaction.
How can I help you explore Laravel packages today?