Forms
DateTimePicker
One field for date, month, time or both — the mode decides the calendar, the format and what is stored.
On this page
Unified date/time picker with date, month, time, and datetime modes.
use NyonCode\WireForms\Components\DateTimePicker;
Modes
// Date onlyDateTimePicker::make('birth_date')->asDate() // DateTime (default)DateTimePicker::make('event_at')DateTimePicker::make('event_at')->asDateTime() // Time onlyDateTimePicker::make('alarm')->asTime() // Month + year only ("YYYY-MM")DateTimePicker::make('period')->asMonth() // Explicit mode setterDateTimePicker::make('x')->mode('date') // 'date', 'month', 'time', 'datetime'
asMonth()always renders the browser-native<input type="month">— the custom calendar has no month-only grid — so it stays native even if you pass->native(false).
asTime()picks the time on a clock dial on a desktop and with steppers on a phone. For a field whose times are slots — opening hours, appointments —TimePickerstores the same value but picks it from a list at a fixed interval instead.
Date Constraints
DateTimePicker::make('start') ->minDate('2024-01-01') ->maxDate('2025-12-31') ->disabledDates(['2024-12-25', '2024-12-31']) ->firstDayOfWeek(1) // Monday ->closeOnDateSelection()
Bounds take anything readable as a date — a Carbon/DateTimeInterface, or a
string such as '2026-07-10', '10.07.2026', 'today' or '+1 week' — and are
reshaped for the widget behind the scenes. A bound that cannot be read at all
throws, rather than being silently dropped by the browser.
DateTimePicker::make('start') ->minDate(now()) // no past dates ->maxDate(now()->addYear())
On a datetime picker a bound may also carry a time, which then limits the
clock on that boundary day only:
DateTimePicker::make('slot') ->minDate('2026-07-10 08:30') // 10 July cannot start before 08:30 ->maxDate('2026-07-20 17:00') // 20 July cannot run past 17:00
A day-granular upper bound covers the whole day: ->maxDate('2026-07-20')
leaves 20 July selectable up to 23:59.
The bounds and the disabled dates are also validation rules. The picker only
draws them, and a drawing can be stepped around — a value typed into the box, or
a phone's own date wheel, which on iOS offers every day regardless of min and
max. So the field adds its own rule (Validation\Rules\DateWithinBounds)
whenever it has anything to hold, and the error names the bound in
displayFormat() when there is one. A field with no bounds and no disabled
dates adds no rule.
Daily Time Window
minDate() and maxDate() bound the days; a time they carry binds on the
first or last day only. Opening hours are a different rule — a time of day that
holds on every day — and that is minTime() / maxTime():
DateTimePicker::make('appointment_at') ->minDate('2026-10-01') ->maxDate('2026-12-31') ->minTime('06:00') // every day from 06:00… ->maxTime('18:00') // …until 18:00 ->minutesStep(15)
How it is resolved:
- Every day. The window applies to the clock of a
timeordatetimepicker on each day the calendar allows. Adateormonthpicker has no clock, so the two are ignored there. - The stricter wins. On a boundary day whose
minDate()/maxDate()carries a time, the later of the two lower bounds (and the earlier of the upper ones) applies. On atimepicker both always apply, since it has a single day.
DateTimePicker::make('slot') ->minDate('2026-10-01 08:30') // 1 October opens at 08:30, not 06:00 ->minTime('06:00') ->maxTime('18:00')
- Drawn, then held. In the panel an hour or minute outside the window is
greyed out, an hour that only straddles it stays pickable and is pulled inside
(picking 18 under
maxTime('18:00')lands on 18:00), and a fresh clock opens on the window rather than on midnight. ATimePicker's slot list and a native slot<select>simply stop at its edges. The server repeats it through the sameDateWithinBoundsrule as the dates, with its own message ("The time in … must not be before 06:00"). - The touch wheel of a
datetimepicker does not grey the window yet; the server still refuses a value outside it. - Format.
H:iorH:i:s('6:00'is read as06:00), aDateTimeInterface(its clock is taken), or a$get-aware Closure. Anything else —'6 pm','25:00'— throws when the field renders, rather than quietly disabling every hour or none.
Time Options
DateTimePicker::make('meeting') ->withSeconds() ->hoursStep(1) ->minutesStep(15) ->secondsStep(30)
Format
DateTimePicker::make('date') ->format('Y-m-d') // storage format ->displayFormat('d.m.Y') // display format ->timezone('Europe/Prague')
format()andtimezone()are opt-in and apply on save. Left unset, the value is stored exactly as the widget produced it — so adding them to an existing field is a deliberate change of what lands in the column, never a silent one.timezone()converts in both directions and only fordatetime: a bare date or time is a wall-clock value that a conversion would corrupt.
displayFormat()uses PHPdate()tokens and only changes what the user sees — the stored value is untouched. It is honoured by the custom picker; a native input's display format belongs to the browser and the user's locale. Left unset, it isdate_format,time_formatordatetime_formatfromconfig/wire-forms.phpfor the mode — except on a field with seconds whose configured format has none, which shows its value as stored.
Desktop Panel
From the field's mobile breakpoint up the panel is built for a mouse and a keyboard; below it the phone keeps its own controls — the bottom sheet with steppers, the native input, or the touch wheel — unchanged. Both are drawn by the same controller over the same state, so a value picked on one is the value the other shows.
- The header pages a month with its arrows, and its month name and year are
buttons: the month opens a grid of twelve months, the year a grid of twenty
years. A date five years back is year → month → day, not sixty clicks. Months
and years entirely outside
minDate()/maxDate()are greyed, and the arrows stop at the bounds. - The clock of a
datetimesits beside the calendar as the clock dialTimePickerdraws — the hours face (1–12 outside, 13–23 and 00 inside), then the minutes, snapping tominutesStep(), with hours and minutes outside the bounds greyed. Here it sets the value as it moves, without an OK of its own: the panel's Done closes it. AnasTime()picker shows the dial alone. - Today / Now in the footer picks the current day (and, with a clock, the
current time rounded down to the step), unless the bounds exclude it. Done
closes a
datetimepanel; Clear empties the field. - The keyboard. ↓ in the box, or the calendar icon, opens the panel with focus on the picked day (else today, pulled inside the bounds); a click into the box opens it and leaves the caret there for typing.
← → previous / next day↑ ↓ previous / next weekHome / End first / last day of the weekPageUp / PageDown previous / next month (with Shift: year)Enter / Space pick the focused day↑ ↓ on the dial previous / next hour or minuteEscape close and return to the box
A day the bounds or disabledDates() exclude is stepped over in the
direction of travel, never landed on.
Typing
The trigger is a text box, not a button: the value can be typed as well as
picked. What is typed is read back through the same format the box shows —
displayFormat() when there is one, the stored shape otherwise — so a field
displaying 9. 3. 2026 14:30 accepts exactly that back.
The parser is loose about everything except the order of the parts, which the
format fixes. Under ->displayFormat('j. n. Y H:i') all of these land on the
same value:
9. 3. 2026 14:309.3.2026 14:309/3/2026 14:309. 3. 26 14:30 a two-digit year is this century9. 3. 2026 no clock typed, so the time already showing is kept
The entry commits on blur and on Enter; Escape abandons it.
Anything the parser cannot read — 31. 2. 2026, an hour past 23, a day that
minDate()/maxDate()/disabledDates() exclude — is refused whole and the
previous value comes back, so a half-read date can never reach the state.
Emptying the box clears the field.
A typed value goes through the same clamp a picked one does: on a boundary day
that carries a time, the clock is pulled inside the bound rather than rejected —
typing 10. 3. 2026 07:00 under ->minDate('2026-03-10 08:30') stores 08:30.
Close the keyboard route where the value really must come from the widget:
DateTimePicker::make('slot')->typeable(false)
readOnly()outrankstypeable(): it closes the keyboard and the panel, because the value is not the user's to change by any route.typeable(false)closes only the keyboard and leaves the calendar working.
Typing is a custom-picker feature. A native input's keyboard belongs to the browser, and the only way to take it away is
readonly— which would disable the browser's own picker along with it — sotypeable(false)has no effect under->native().
Native Picker
The custom Alpine picker is the default. Opt out to the browser's own control:
DateTimePicker::make('date') ->native() // use the browser-native picker ->native(false) // back to the custom picker (default)
The only exception is asMonth(), which is always native.
A native time or datetime input carries a step taken from the finest unit you
configured — secondsStep() (or 1 with withSeconds()), else minutesStep(),
else hoursStep(). That is what makes a native input show seconds at all (the
browser's default step is a minute), and the stride its own validation holds the
value to. displayFormat() and typeable() belong to the custom picker; a
native input formats and types the way the browser's locale says.
Native on phones only
The calendar is comfortable with a mouse and cramped on a phone, where the
browser already has a date wheel the user knows. nativeOnMobile() keeps the
custom picker from the field's mobile breakpoint
up and renders the browser's input below it:
DateTimePicker::make('starts_at') ->minDate('today') ->minutesStep(15) ->nativeOnMobile() // phone: native datetime-local; desktop: the calendar
Both are in the markup, bound to the same state, and CSS at the breakpoint shows
one — nothing is decided in the browser, so nothing flashes. The native twin
takes its own id ({id}-native) and names itself; required() becomes
aria-required on both halves, since a required input the stylesheet hides
would block the form on the other screen size. The calendar's bottom sheet is not
rendered, because the native input owns that screen.
native() wins over it. Every picker goes native on a phone — the custom one
is the harder control to use there — and what a phone's own control cannot show
is made up for another way:
- A clock step (
minutesStep(),hoursStep()on atimeordatetime) never goes on<input type="time">: to the browserstepis a validation rule, not a wheel, and iOS offers every minute whatever it says. A stepped time is a native<select>of the slots; a stepped datetime is a native date input beside that select, joined into the one state in the browser (a date alone writes nothing). - Disabled dates and the bounds cannot be greyed out in a phone's wheel (iOS
ignores
min/maxtoo), so the server refuses them — see date constraints. - Seconds — a native time input carries
stepin seconds, which Android shows; the iOS wheel has no seconds and saves:00.
The app-wide default is wire-core.mobile.native; an explicit native(false)
opts a field out of it.
Touch wheel on phones
A phone can get neither the desktop panel nor the browser's control but a touch wheel: columns in a bottom sheet that coast and snap like the phone's own picker — hours and minutes for a time, day / month / year for a date, and a day column beside the clock for a datetime (the iOS shape: "Mon 21/9 · 14 : 30").
TimePicker::make('opens_at') ->minDate('08:00')->maxDate('18:00') ->touchOnMobile() // phone: the wheel; desktop: the picker
- Each column is an ordinary scroller with CSS scroll-snap, so the momentum and the snap are the platform's own physics. The rows tilt like a drum, and on Android a turn gives a short vibration (the web has no haptics on iOS).
- The clock columns come from the field's slots (interval and bounds applied);
a date's years and a datetime's days run between
minDate()andmaxDate(). A combination the field would refuse is greyed and never kept: past a bound the wheels land on the bound itself, 31 February rolls the day back to the 28th, a disabled day steps to its neighbour, and a clock value that is not a slot rolls the other column. - Nothing is written while the wheel turns: Done commits, Cancel, the backdrop and a swipe down on the grabber leave the value; Clear empties it. An empty field opens on the next slot from now.
- Each column is a
spinbuttonfor screen readers and the keyboard (arrows, Home/End); the sheet traps focus and locks the page scroll while it is open. - Months and weekdays are named in the page's locale (
<html lang>). - Every mode but
asMonth(). It wins overnativeOnMobile();native()wins over it. It follows the field'smobileBreakpoint(), and the app-wide default iswire-core.mobile.touch— the same switch that gives selects their touch list.
Methods
| Method | Type | Description |
|---|---|---|
mode(string) |
string | Set mode: date, month, time, datetime |
asDate() |
— | Alias for mode('date') |
asTime() |
— | Alias for mode('time') |
asMonth() |
— | Alias for mode('month'); always native |
asDateTime() |
— | Alias for mode('datetime') |
format(string) |
string | Storage format (Carbon compatible) |
displayFormat(string) |
string | Display format shown to the user; default from wire-forms.date_format / time_format / datetime_format |
minDate(string|DateTimeInterface|Closure) |
string | Earliest selectable date; may carry a time on a datetime picker |
maxDate(string|DateTimeInterface|Closure) |
string | Latest selectable date; a day-granular bound covers the whole day |
disabledDates(array|Closure) |
array | Dates that cannot be selected |
firstDayOfWeek(int) |
int | 0=Sunday, 1=Monday |
closeOnDateSelection() |
bool | Close picker after a date is selected |
withSeconds() |
bool | Show seconds column in time picker |
hoursStep(int) |
int | Hour increment step |
minutesStep(int) |
int | Minute increment step |
secondsStep(int) |
int | Second increment step |
minTime(string|Closure|null) |
string | Earliest time of day on every day, H:i/H:i:s; time/datetime only — see daily time window |
maxTime(string|Closure|null) |
string | Latest time of day on every day |
touchOnMobile(bool $condition = true) |
bool | Touch wheel in a bottom sheet below the mobile breakpoint; not for asMonth() (default: false) — see touch wheel on phones |
timezone(string) |
string | Show the value in this timezone and convert back to the app timezone on save; datetime only |
native(bool $native = true) |
bool | Use the browser-native control instead of the custom picker (default: false) |
nativeOnMobile(bool $condition = true) |
bool | Browser-native control below the mobile breakpoint only, the custom picker above it (default: wire-core.mobile.native, false); a clock step renders a native date + slot <select> |
typeable(bool|Closure) |
bool | Let the value be typed into the input as well as picked (default: true); custom picker only |
disabled(bool|Closure) |
bool | Disable the picker |
readOnly(bool|Closure) |
bool | Read-only mode — no typing and no panel |
required() |
— | Mark as required |
live() |
— | Trigger Livewire update on change |
See Common Field API for label, hint, tooltip, and other shared methods.