coolsam/flatpickr
Laravel integration for Flatpickr, the lightweight JavaScript date and time picker. Ships Blade components and helpers to render configurable datepickers with localization, ranges, and custom formats, keeping form markup clean and JavaScript minimal.
First, confirm you actually need it. Filament v3's own DatePicker/DateTimePicker is already a Flatpickr wrapper and exposes ->options([...]) for raw config. If all you need is a single date with min/max, you already have it — skip the dependency. This package earns its place for the narrower, opinionated cases: range mode, multiple-date mode, inline always-visible calendars, altFormat display-vs-stored-value, or using Flatpickr in a plain Blade view outside Filament entirely.
Check the constraint matrix before installing. Open the package's composer.json and read it against your installed PHP, Laravel, Filament, and Livewire versions. This tracks the Filament v3 era; v4 and late v3 minors are unproven. Pin an exact patch version rather than floating:
composer require coolsam/flatpickr:1.x.y
Read the vendor source, not the README. Public docs are thin, and the README can lag the code. The authoritative API surface is:
vendor/coolsam/flatpickr/src/ # field class, Blade component, service provider
vendor/coolsam/flatpickr/resources/ # the view — shows every option it forwards
Note the exact class namespace and Blade component tag before you write anything; the source is the contract.
Register and publish assets deliberately. Run the publish command via the provider's tag, then check whether the shipped JS is precompiled or needs a build step:
php artisan vendor:publish --provider="Coolsam\Flatpickr\FlatpickrServiceProvider"
If you have a Content Security Policy, decide now between published assets and a CDN, and test it in the target environment — a blocked asset fails silently into a plain text input with no error.
First use case: an inline calendar or a date-range filter. That is where the visible win over native is largest and the blast radius smallest. Drop it on a scratch admin page, confirm the calendar opens and posts a value, then wire it into exactly one Resource form.
Wrap it before you spread it. Create an internal component so no Resource file ever imports the vendor class directly (see the next section). This ten-minute investment is what turns "fork or revert" into a localized one-file change if the package goes stale.
The internal wrapper is the default pattern. Every form should reference your class, never the vendor's:
namespace App\Filament\Forms\Components;
use Coolsam\Flatpickr\...\Flatpickr as BaseFlatpickr; // verify exact namespace in vendor/
class DateRangePicker extends BaseFlatpickr
{
protected function setUp(): void
{
parent::setUp();
$this->options([
'mode' => 'range',
'dateFormat' => 'Y-m-d',
'altInput' => true,
'altFormat' => 'j M Y',
'locale' => ['rangeSeparator' => ' to '],
]);
}
}
When the package goes stale or you decide to fork, the diff is one file.
Set panel-wide defaults once via configureUsing in a service provider, so you are not repeating fluent calls across every resource:
// AppServiceProvider::boot()
\App\Filament\Forms\Components\Flatpickr::configureUsing(function ($field) {
$field->options(array_merge(
['dateFormat' => 'Y-m-d H:i', 'allowInput' => true, 'time_24hr' => true],
$field->getOptions() ?? [],
));
});
Keep one format-constants class as the single source of truth. Flatpickr tokens are not Carbon tokens, and drift between form config, casts, and test fixtures is how wrong rows get written:
final class DateFormats
{
public const DATE_UI = 'j M Y'; // displayed (altFormat)
public const DATE_STORE = 'Y-m-d'; // stored (dateFormat)
public const DATETIME_UI = 'j M Y H:i';
}
Normalize state at the field boundary. This is the highest-value convention you can adopt. Flatpickr sends strings, and empty input often arrives as '' rather than null, which breaks nullable writes and whereDate comparisons:
Flatpickr::make('starts_at')
->options(['dateFormat' => DateFormats::DATETIME_STORE, 'enableTime' => true])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null);
Cast to Carbon in the model, not in the field — keep the field presentation-only.
Range and multiple modes produce array state. Verify the shape end-to-end before binding it to a Livewire filter or query, because whereBetween silently returns nothing when one end is empty:
Flatpickr::make('period')
->options(['mode' => 'range', 'dateFormat' => 'Y-m-d'])
->live()
->afterStateUpdated(fn ($state, $set) => $set('from', $state[0] ?? null));
Client-side options are UX; server-side rules are validation. minDate, maxDate, disable and enable are only hints and are bypassable via allowInput. Mirror them in rules:
->options(['minDate' => 'today', 'disable' => [fn ($d) => $d->getDay() === 0]])
->rules(['required', 'date', 'after_or_equal:today'])
Plain Blade usage follows the same idea outside Filament. Bind old() for redisplay, echo @error, and remember Laravel's form helpers won't translate a Flatpickr token for you — set value from the model and let the field's dateFormat render it. Use wire:ignore on a wrapper when the input lives inside a Livewire component that re-renders.
Test it as a form field, not as JavaScript. A Livewire test that fills the field and asserts dehydrated state catches most regressions:
Livewire::test(EditBooking::class)
->fillForm(['starts_at' => '2025-03-04 09:00'])
->assertHasNoFormErrors()
->assertSet('data.starts_at', '2025-03-04 09:00');
Then add one test that inserts a repeater row dynamically — that is the case most likely to break when Livewire morphs the DOM and Flatpickr never initializes the new input.
S (not s), 12-hour is h with K for AM/PM (not A/a). And dateFormat (stored) must agree with altFormat (displayed, requires altInput). Getting this wrong produces a correct-looking UI and wrong database rows.->toDate() on something you never received. If you need a Carbon, cast inside dehydrateStateUsing and document it in one place.'' versus null. The most common silent defect in this class of field. Normalize on dehydrate and assert it in a test.key() per row and a wire:ignore wrapper where appropriate — then actually test adding a row rather than assuming the fix worked.options() array is the extension point. The wrapper is thin by design; anything not exposed as a fluent method is still reachable through options. When options still can't reach it, subclass and override the view rather than patching vendor.How can I help you explore Laravel packages today?