malzariey/filament-daterangepicker-filter
Native Alpine.js date range picker and filter for Filament with day, month, and year modes, presets, optional time selection, keyboard input, localization, and modal/slide-over compatibility. Includes drop-in DateRangePicker field and DateRangeFilter.
Install and confirm the Filament major version first. The 5.x line targets Filament v4 (Alpine.js + Day.js, no jQuery). If you are still on Filament v3, you need the older 3.x/4.x release of this package — check composer.json constraints before running anything, because the APIs differ (see Gotchas).
composer require malzariey/filament-daterangepicker-filter
# optional, only if you ship non-English UIs
php artisan vendor:publish --tag="filament-daterangepicker-filter-translations"
There is no config file to publish and no service provider step — Filament auto-discovers it. If the popup renders unstyled, run php artisan filament:assets and hard-refresh.
Where to look first
Malzariey\FilamentDaterangepickerFilter\Filters\DateRangeFilter — the class you will use 90% of the time.Malzariey\FilamentDaterangepickerFilter\Fields\DateRangePicker — the form-field twin, with the full fluent API (monthPicker(), timePicker(), ranges(), useDualState(), …).Malzariey\FilamentDaterangepickerFilter\Enums\{DropDirection, OpenDirection, PickerType}.vendor/malzariey/filament-daterangepicker-filter/ — with ~178 stars this is a small project, so reading the source is a realistic debugging strategy when the README runs out.First use case — filter any table by a date column. This is the whole value proposition in one line:
use Malzariey\FilamentDaterangepickerFilter\Filters\DateRangeFilter;
public static function table(Table $table): Table
{
return $table
->filters([
DateRangeFilter::make('created_at'),
]);
}
That gives you a two-calendar range picker with presets, and the default query already does the right thing on created_at. Start here, ship it, then layer on presets and limits.
The default query covers the boring case; reach for modifyQueryUsing the moment reality diverges. You get $startDate, $endDate (both ?Carbon) and the raw $dateString, so you can decide whether the filter was actually applied:
DateRangeFilter::make('created_at')
->modifyQueryUsing(fn (Builder $query, ?Carbon $start, ?Carbon $end, $raw) =>
$query->when($raw, fn ($q) => $q->whereBetween('created_at', [$start, $end]))
)
Use this for: a column with a different name than the filter key, a relation (whereHas('order', fn ($q) => $q->whereBetween('placed_at', [...]))), a soft-deleted/archived union, or a scope that already encodes tenancy. Always keep the when($raw, …) guard — an unset filter must not silently narrow results.
Reporting presets are the killer feature; centralise them. Every report screen should offer the same buttons, so put the definition in one place rather than copy-pasting closures into a dozen resources:
// app/Filament/Support/DateRanges.php
public static function presets(): array
{
return [
'Today' => [now()->startOfDay(), now()->endOfDay()],
'Yesterday' => [now()->subDay()->startOfDay(), now()->subDay()->endOfDay()],
'Last 7 days' => [now()->subDays(6)->startOfDay(), now()->endOfDay()],
'This month' => [now()->startOfMonth(), now()->endOfMonth()],
'Last quarter' => [now()->subQuarter()->startOfQuarter(), now()->subQuarter()->endOfQuarter()],
];
}
DateRangeFilter::make('created_at')
->ranges(DateRanges::presets())
->useRangeLabels() // shows "Last 7 days" instead of two dates
->withIndicator() // visible "filter is live" affordance on the trigger
Pair useRangeLabels() + disableCustomRange() when you want a report period selector rather than an ad-hoc filter — it stops users from producing ranges your aggregates don't support.
Guardrails beat validation messages. For dashboards backed by expensive queries or monthly partitions, constrain at the picker so the bad query never happens:
DateRangeFilter::make('created_at')
->minDate(now()->subYear()->startOfYear())
->maxDate(now())
->maxSpan(['months' => 3])
->firstDayOfWeek(1); // Monday-first for EU/ME users
Month and year granularity are first-class. Billing/finance screens should not force users to pick the 1st and the 31st:
DateRangeFilter::make('billing_period')
->monthPicker()
->format('m/Y')
->allowInput() // "06/2026 - 08/2026"
->minYear(2020)
->maxYear(2030);
DateRangeFilter::make('fiscal_year')
->yearPicker()->format('Y')->minYear(2015)->maxYear(2035);
Add timePicker() only where time genuinely matters (audit logs, sync windows, incident timelines). ->timePicker()->timePicker24()->timePickerIncrement(15) is a sane default for ops screens. For everything business-facing, leave it off — an accidental 14:00 start silently excludes the morning.
Persistence is the field's job, not the filter's. A filter is ephemeral table state; a DateRangePicker in a form is data entry. Two patterns:
DateRangePicker::make('date_range')
->useDualState('starts_at', 'ends_at')
->defaultThisMonth();
With useDualState, put public ?string $starts_at / $ends_at on the Livewire component (or make sure those attribute names exist on the model). defaultToday(), defaultLast7Days(), defaultThisMonth(), defaultLastYear() are handy for "new record" forms, but be deliberate: a default on an edit form can overwrite existing data if the user saves without touching the field.
Wire the JS events when the picker must drive something outside Livewire — a Chart.js canvas, a map viewport, an export range preview:
document.addEventListener('apply.daterangepicker', (e) => {
chart.setRange(e.detail.startDate, e.detail.endDate);
});
Prefer Livewire's own reactive binding for state you need server-side; use these events only for client-only consumers. Note they are document-level — scope them by checking e.target if you have multiple pickers on a page.
Modals and slide-overs are the reason teleport() exists. It is on by default and is what stops the dropdown from being clipped or z-buried inside a Filament action modal. Leave it on; only disable when your own CSS depends on DOM ancestry.
Testing: assert the filter through Filament's table-testing helpers and be aware that the filter state is a [start, end] pair — write tests for the empty case (filter not applied → all rows) as well as the applied case. That is the regression class that bites hardest, and it is cheap to cover.
The 5.0.7 timezone fix is the single most important thing to understand. Before it, getSystemTimezone() returned Filament's display timezone instead of config('app.timezone'). If anyone ever calls FilamentTimezone::set() — the documented way to show local times over UTC storage — the conversion became a no-op and local wall-clock strings were bound against UTC data. Result: every row in the first hours of a local day was filed under the previous day, silently. One day's filter at Asia/Riyadh bound 2026-08-18 00:00:00–23:59:59 +03, and a row stored at 2026-08-17 22:00:00 was excluded. If you are on anything below 5.0.7 and you set a display timezone, upgrade today and spot-check a boundary date.
After 5.0.7: getTimezone() = display zone, getSystemTimezone() = config('app.timezone'). For a column genuinely stored in a different zone, be explicit:
DateRangeFilter::make('created_at')->systemTimezone('Asia/Riyadh');
Also worth knowing: ->timezone('UTC') on the field sets the picker's input zone, which is a different knob from systemTimezone(). Do not treat them as synonyms.
Upgrading from 3.x/4.x is a rewrite, not a patch. jQuery and Moment.js are gone. Concretely: replace every ->displayFormat('DD/MM/YYYY') with ->format('d/m/Y'), and replace the set*Option() / get*Option() family with the short methods (timePicker(), autoApply(), linkedCalendars(), timePickerIncrement(), …). The deprecated methods still resolve, so a grep-and-replace is safe to do incrementally — but anything you copied from a pre-5 blog post is a trap.
Format tokens are PHP Carbon, auto-converted to Day.js for the browser. Pass d/m/Y, not DD/MM/YYYY. The one exception is the deprecated displayFormat(), which takes Day.js tokens — mixing the two in one codebase produces baffling off-by-one-day and month-name bugs.
Boundary semantics deserve a test, not an assumption. whereBetween('created_at', [$start, $end]) is inclusive at both ends only if $end carries 23:59:59. If your modifyQueryUsing builds the end yourself, either use endOfDay() or — better for indexed columns — query < $end->addDay()->startOfDay(). Never wrap the column: whereDate('created_at', …) and DATE(created_at) BETWEEN … kill the index and turn a fast filter into a table scan.
disabledDates() and minDate()/maxDate() are UI constraints only. With allowInput() enabled a user can type their way past them. Validate server-side in the filter closure or a form request; the picker is a convenience, not a security boundary.
allowInput() demands an exact format match. If format('d/m/Y') is set, 2026-08-18 will not parse. Partial or invalid input deliberately does not clobber the last valid selection — which is good UX but means "I typed something and nothing happened" is the normal failure mode. Make the placeholder/help text unambiguous, and remember Enter applies the typed value while the caret is in the input.
Changing ->format() later is a state migration. Filter state lives in the query string / Livewire state. Bookmarked or shared URLs containing a d/m/Y range will not parse after you switch to Y-m-d; users get an empty, unexplained table. Treat the format on an existing screen as part of its public API.
Presets and the time picker fight each other. A preset defined as [now(), now()] will not match a selection made when times differ, so the "active range" highlight can look wrong. 5.0.1 specifically fixed timed-preset matching (active range + range labels) — if you see a preset that never highlights, check your package version before writing custom JS. Keep presets day-bounded (startOfDay()/endOfDay()) unless the screen is time-precise.
Popover direction matters on tall pages. If the trigger sits near the viewport bottom or inside a short modal, set it explicitly rather than hoping:
->drops(DropDirection::UP)->opens(OpenDirection::CENTER)
Localization is Day.js, not Laravel. Publishing the translations covers the package's own strings; month/day names come from Day.js locales driven by the app locale. A half-translated picker (English months, Arabic chrome, or vice versa) almost always means the Day.js locale bundle for that locale is not loaded.
Budget for a small-community dependency. 178 stars and one primary maintainer means you will occasionally be the first to hit an edge case. Pin the minor version in composer.json, keep the date-filter behaviour of your key reports under test (empty case + boundary case + timezone case), and be willing to patch or wrap the filter in your own app/Filament/Filters/AppDateRangeFilter.php subclass — that subclass is also the right place to put your house ranges(), maxSpan(), and indicator defaults so new resources can't drift.
How can I help you explore Laravel packages today?