Validating Dates and Times in Laravel
Dates arrive from forms as strings. A birthday field with abc123 or 31/02/2025 will pass through to the database without validation, causing silent data corruption or unexpected errors down the line. Laravel provides a set of rules for checking dates, date ranges, and timezones.
For general validation concepts, see the quick start guide. For numeric rules like min, max, and between, see strings and numbers.
The date Rule
The date rule checks that the value is a valid, non-relative date using PHP’s strtotime(). It accepts most common formats:
$request->validate([
'birthday' => ['required', 'date'],
'hired_at' => ['nullable', 'date'],
]);
Strings like 2025-03-15, March 15, 2025, and 15.03.2025 all pass – anything strtotime can parse as an absolute date. Relative expressions like next Monday or +3 days are rejected, even though strtotime normally understands them.
The flexibility of date is also its weakness: if you need a specific input format, date is too permissive. Use date_format for strict format enforcement.
A related rule, date_equals, checks that the value matches an exact date:
$request->validate([
'confirmation_date' => ['required', 'date', 'date_equals:2026-01-01'],
]);
Rare in practice, but useful when a form asks the user to confirm a fixed deadline.
Enforcing a Format with date_format
When the input must match a specific format, use date_format instead of date. It accepts one or more format strings:
$request->validate([
'birthday' => ['required', 'date_format:Y-m-d'],
'event_date' => ['required', 'date_format:d.m.Y'],
'flexible' => ['required', 'date_format:Y-m-d,d/m/Y'],
]);
The formats follow PHP’s DateTime::createFromFormat() syntax. Common patterns:
Y-m-d–2025-03-15(ISO, recommended for APIs)d.m.Y–15.03.2025(common in Europe)d/m/Y–15/03/2025m/d/Y–03/15/2025(US format)Y-m-d H:i:s–2025-03-15 14:30:00(datetime)Y-m-d\TH:i:sP– ISO 8601 with timezone offset
Do not use date and date_format together on the same field. date relies on strtotime, date_format on DateTime::createFromFormat – different parsing engines that can contradict each other.
A gotcha with dd/mm/yyyy format: date_format:d/m/Y is strict about leading zeros. 5/3/2025 fails because the day and month need two digits (05/03/2025). If your frontend strips leading zeros, either pad in prepareForValidation() or accept both with multiple formats: date_format:d/m/Y,j/n/Y.
// Strict: requires 05/03/2025
'event_date' => ['required', 'date_format:d/m/Y'],
// Flexible: accepts both 05/03/2025 and 5/3/2025
'event_date' => ['required', 'date_format:d/m/Y,j/n/Y'],
The Rule::date() Fluent Builder
Laravel provides Rule::date() as a fluent alternative:
use Illuminate\Validation\Rule;
$request->validate([
'start_date' => [
'required',
Rule::date()->format('Y-m-d'),
],
]);
Equivalent to date_format:Y-m-d, but more readable when chained with range constraints. The real benefit shows up when combining format and bounds in one call.
Date Ranges: after and before
The after and before rules compare a date against a boundary. The boundary is parsed through strtotime, so both absolute dates and relative expressions work:
$request->validate([
'departure' => ['required', 'date', 'after:tomorrow'],
'return' => ['required', 'date', 'after:departure'],
]);
In the first case, the departure must be later than tomorrow. In the second, the return date must be after the departure. Laravel detects when the boundary is the name of another field in the request and compares against its value automatically.
after_or_equal and before_or_equal
Inclusive variants – the date may equal the boundary:
$request->validate([
'check_in' => ['required', 'date', 'after_or_equal:today'],
'check_out' => ['required', 'date', 'after:check_in'],
]);
Check-in can be today (after_or_equal:today), checkout must be strictly after check-in.
$request->validate([
'birthday' => ['required', 'date', 'before:today'],
'start_date' => ['required', 'date', 'before_or_equal:end_date'],
]);
Birthday must be in the past. Start date must not be later than end date.
The keyword “date greater than” maps directly to after – there is no separate greater_than rule for dates. Use after:other_field to require one date to be greater than another.
Fluent Syntax for Ranges
Rule::date() makes range chains cleaner, especially with computed boundaries:
$request->validate([
'event_date' => [
'required',
Rule::date()
->format('Y-m-d')
->todayOrAfter()
->before(now()->addMonths(6)),
],
'birth_date' => [
'required',
Rule::date()
->format('Y-m-d')
->beforeToday(),
],
]);
Available methods: after($date), afterOrEqual($date), before($date), beforeOrEqual($date), afterToday(), todayOrAfter(), beforeToday(), todayOrBefore(), past(), future(), nowOrPast(), nowOrFuture(), between($from, $to), betweenOrEqual($from, $to).
Relative strtotime Expressions
The string version of after and before accepts anything strtotime understands:
$request->validate([
'delivery_date' => ['required', 'date', 'after:+3 days'],
'deadline' => ['required', 'date', 'before:+1 year'],
'meeting' => ['required', 'date', 'after:next monday'],
]);
Convenient but fragile – +3 days is computed from the server’s current time. In tests, control this with $this->travelTo() to avoid flaky assertions.
Date Between Two Values
Laravel has no date_between rule. To constrain a date to a range, combine after_or_equal and before_or_equal:
$request->validate([
'event_date' => [
'required',
'date_format:Y-m-d',
'after_or_equal:2026-01-01',
'before_or_equal:2026-12-31',
],
]);
With the fluent builder, use betweenOrEqual for cleaner range checks:
'reservation_date' => [
'required',
Rule::date()
->format('Y-m-d')
->betweenOrEqual(now(), now()->addDays(90)),
],
This is equivalent to combining todayOrAfter() and beforeOrEqual() but reads as a single constraint.
Validating Time
Laravel has no dedicated time rule. Validate times using date_format with time-only format strings:
$request->validate([
'start_time' => ['required', 'date_format:H:i'],
'precise_time' => ['required', 'date_format:H:i:s'],
]);
H:i accepts 14:30, H:i:s accepts 14:30:00. For 12-hour format with AM/PM:
'time' => ['required', 'date_format:g:i A'], // 2:30 PM
Datetime Fields
For fields that contain both date and time:
$request->validate([
'starts_at' => ['required', 'date_format:Y-m-d H:i'],
'ends_at' => ['required', 'date_format:Y-m-d H:i', 'after:starts_at'],
]);
The after comparison includes both date and time – values are parsed as complete timestamps.
ISO 8601 with Timezone
For full datetime strings from JavaScript frontends:
'starts_at' => ['required', 'date_format:Y-m-d\TH:i:sP'],
// accepts 2025-03-15T14:30:00+03:00
The P specifier matches a UTC offset like +03:00. The T between date and time is escaped with a backslash.
Timestamp Validation
Unix timestamps (seconds since 1970-01-01) have no dedicated rule. Use integer with bounds:
$request->validate([
'created_after' => ['required', 'integer', 'min:0'],
'expires_at' => ['required', 'integer', 'min:0', 'max:4102444800'],
]);
The upper bound of 4102444800 is year 2100 – a sanity check against absurd values.
JavaScript’s Date.now() returns milliseconds. Either divide in prepareForValidation() or validate the 13-digit length:
'js_timestamp' => ['required', 'digits:13'],
If the API accepts a timestamp but the model stores a datetime column, convert in prepareForValidation():
protected function prepareForValidation(): void
{
if ($this->expires_at && is_numeric($this->expires_at)) {
$this->merge([
'expires_at' => Carbon::createFromTimestamp($this->expires_at)->format('Y-m-d H:i:s'),
]);
}
}
Timezone Validation
The timezone rule checks the value against DateTimeZone::listIdentifiers():
$request->validate([
'tz' => ['required', 'timezone'],
]);
Accepts strings like Europe/London, America/New_York, UTC. Filter by region or country:
// Europe only
'tz' => ['required', 'timezone:Europe'],
// Specific country
'tz' => ['required', 'timezone:per_country,US'],
Available regions: Africa, America, Antarctica, Arctic, Asia, Atlantic, Australia, Europe, Indian, Pacific. Value all (the default) includes everything.
Practical Recipes
Age Verification
No built-in “minimum age” rule, but before solves it:
$request->validate([
'birthday' => [
'required',
'date_format:Y-m-d',
'before:-18 years',
'after:-120 years',
],
]);
before:-18 years means the birthday must be earlier than 18 years ago from today. strtotime('-18 years') computes the boundary. With the fluent builder:
'birthday' => [
'required',
Rule::date()
->format('Y-m-d')
->beforeOrEqual(now()->subYears(18))
->after(now()->subYears(120)),
],
Working Hours
Time-only boundaries cannot be expressed with after/before since those rules expect full dates. Use an after() hook or a custom Rule:
class WorkingHours implements ValidationRule
{
public function __construct(
private string $from = '09:00',
private string $to = '18:00',
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value < $this->from || $value > $this->to) {
$fail("The :attribute must be between {$this->from} and {$this->to}.");
}
}
}
Usage:
'appointment_time' => ['required', 'date_format:H:i', new WorkingHours('10:00', '19:00')],
String comparison is safe here because date_format:H:i guarantees the two-digit format. More on custom rules in the custom rules article.
Year Validation
A year is just a four-digit number. No date rule needed:
$request->validate([
'birth_year' => ['required', 'integer', 'digits:4', 'min:1900', 'max:' . date('Y')],
'graduation' => ['required', 'integer', 'digits:4', 'min:2000', 'max:2100'],
]);
For year + month (e.g., credit card expiry), use date_format:
'card_expiry' => ['required', 'date_format:m/Y', 'after:today'],
m/Y accepts 03/2027. The after:today check ensures the card has not expired.
Weekday-Only Dates
Check that a date falls on a weekday using an after() hook:
public function after(): array
{
return [
function ($validator) {
$date = $this->validated('delivery_date') ?? null;
if ($date && Carbon::parse($date)->isWeekend()) {
$validator->errors()->add('delivery_date', 'Delivery is not available on weekends.');
}
},
];
}
A Complete Booking Form
Tying it all together – a booking form with date, time, and timezone:
class BookingRequest extends FormRequest
{
public function rules(): array
{
return [
'date' => [
'required',
Rule::date()
->format('Y-m-d')
->todayOrAfter()
->beforeOrEqual(now()->addDays(90)),
],
'time' => ['required', 'date_format:H:i', new WorkingHours],
'timezone' => ['required', 'timezone'],
'duration_minutes' => ['required', 'integer', 'in:30,60,90'],
];
}
public function after(): array
{
return [
function ($validator) {
$date = $this->validated('date') ?? null;
if ($date && Carbon::parse($date)->isWeekend()) {
$validator->errors()->add('date', 'Bookings are only available on weekdays.');
}
},
];
}
}
This combines Rule::date() for the date with a custom rule for working hours, timezone for the user’s timezone, and an after() hook for the weekday check. Each piece uses the right tool – built-in rules where they fit, custom logic where they do not.
Storing Dates in the Database
MySQL and PostgreSQL store dates as Y-m-d (DATE) and Y-m-d H:i:s (DATETIME). Two approaches:
- Validate in the database format directly (
date_format:Y-m-d) sovalidated()output goes straight into the model - Accept a user-friendly format (
date_format:d/m/Y), convert inprepareForValidation(), then pass to the model
If the model uses a date or datetime cast, Eloquent handles conversion from Carbon to the database format automatically. The key is that the value must be a valid date by the time it reaches the model.
Example of the second approach – accepting dd/mm/yyyy from the user, storing as Y-m-d:
protected function prepareForValidation(): void
{
if ($this->event_date) {
$parsed = Carbon::createFromFormat('d/m/Y', $this->event_date);
if ($parsed) {
$this->merge(['event_date' => $parsed->format('Y-m-d')]);
}
}
}
public function rules(): array
{
return [
'event_date' => ['required', 'date_format:Y-m-d', 'after:today'],
];
}
The user types 15/03/2026, prepareForValidation() converts it to 2026-03-15, and the rules validate the normalized value. The validated() output contains the database-ready format.
Nullable and Optional Dates
Optional date fields need nullable – without it, the ConvertEmptyStringsToNull middleware turns "" into null, and null fails the date check:
$request->validate([
'start_date' => ['required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after:start_date'],
]);
When end_date is null, the nullable rule short-circuits the chain – date_format and after are skipped entirely. The field passes and appears as null in validated().
For PATCH requests where the date field may be absent from the payload:
'deadline' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after:today'],
sometimes skips the field if the key is missing. nullable allows null. Together they handle the “not sent” and “explicitly cleared” cases. The difference between these modifiers is explained in the rules reference.
Handling Multiple Timezones
When users in different timezones submit dates, consider whose “today” you are comparing against. A user in Tokyo submitting at 2 AM March 16 JST is still on March 15 in New York.
One approach – accept a timezone alongside the date and compute boundaries accordingly:
public function rules(): array
{
return [
'timezone' => ['required', 'timezone'],
'event_date' => [
'required',
'date_format:Y-m-d',
],
];
}
public function after(): array
{
return [
function ($validator) {
$tz = $this->validated('timezone') ?? 'UTC';
$eventDate = Carbon::createFromFormat('Y-m-d', $this->event_date, $tz);
$today = Carbon::now($tz)->startOfDay();
if ($eventDate->lt($today)) {
$validator->errors()->add('event_date', 'The event date must be today or later in your timezone.');
}
},
];
}
Another approach – require the frontend to send UTC datetimes (Y-m-d\TH:i:sZ) and convert to the user’s timezone only for display. This avoids timezone math in validation entirely and simplifies the backend logic significantly.
Common Date Validation Mistakes
Using date when you need date_format – date accepts March 15, 2025 and 15.03.2025 equally. If your API contract requires ISO format, date will not reject European-style input. Always use date_format:Y-m-d for strict format enforcement.
Mixing date and date_format on the same field – they use different parsing engines and can produce contradictory results. Pick one.
Comparing times with after/before – these rules compare full timestamps. after:09:00 does not mean “after 9 AM” – strtotime('09:00') resolves to today at 9 AM, which may or may not be what you want. For time-only boundaries, use a custom rule or an after() hook.
Forgetting nullable on optional date fields – an empty date input becomes null after middleware. Without nullable, the date rule rejects null. Optional dates always need nullable|date.
Relative dates in tests – after:tomorrow or before:+30 days is computed from the server clock. Tests that run at midnight may pass or fail depending on timezone. Use $this->travelTo() to freeze time.
after with another field when that field is empty – if the reference field (start_date) is nullable and arrives as null, the after:start_date rule on end_date may behave unexpectedly. Combine with required_with to ensure the reference field is present when you need the comparison:
'end_date' => ['required_with:start_date', 'nullable', 'date', 'after:start_date'],
Format differences between frontend and backend – JavaScript’s Date.toISOString() outputs 2025-03-15T14:30:00.000Z with milliseconds. Laravel’s date_format:Y-m-d\TH:i:sP does not match the .000Z part. Either strip milliseconds in prepareForValidation() or use a regex to normalize the input before validation runs.
Testing Date Validation
public function test_event_date_must_be_in_the_future(): void
{
$this->travelTo('2026-06-01 12:00:00');
$response = $this->post('/events', [
'title' => 'Conference',
'event_date' => '2026-05-01',
]);
$response->assertSessionHasErrors('event_date');
}
public function test_valid_future_date_passes(): void
{
$this->travelTo('2026-06-01 12:00:00');
$response = $this->post('/events', [
'title' => 'Conference',
'event_date' => '2026-07-15',
]);
$response->assertSessionHasNoErrors();
}
travelTo() freezes now() and today(), making date validation tests deterministic regardless of when the test suite runs.
For a full list of all validation rules, see the rules reference. Conditional date logic (requiring a date only when another field is set) is covered in the conditional validation article. Error message customization for date fields is in the error messages article. For validating arrays of dates (e.g., a list of event dates), see arrays and JSON. The Form Request article covers prepareForValidation() and after() hooks in detail.
Other validation topics: unique and exists for database checks, files and images for upload validation, passwords for password complexity rules.