Filtering records between two dates is one of the most common reporting queries, and Eloquent's whereBetween() handles it cleanly — the detail that actually trips people up is making sure the end date genuinely includes the whole day, not just midnight of that date.
The basic whereBetween query
$orders = Order::whereBetween('created_at', [$startDate, $endDate])->get();
The common gotcha: an end date that silently excludes that day's records
// if $endDate is "2024-03-15" (interpreted as 2024-03-15 00:00:00),
// this MISSES every order created after midnight on the 15th
$orders = Order::whereBetween('created_at', ['2024-03-01', '2024-03-15'])->get();
A plain date string is interpreted with a time of midnight — comparing a full datetime column against a bare date silently excludes anything from later that same day, which is rarely the intended behavior for an "up to and including this date" report.
Fixing it: extend the end date to the end of that day
use Carbon\Carbon;
$start = Carbon::parse($request->start_date)->startOfDay();
$end = Carbon::parse($request->end_date)->endOfDay();
$orders = Order::whereBetween('created_at', [$start, $end])->get();
endOfDay() moves the boundary to the end of that calendar day, so records from later on the end date are included instead of stopping at its midnight boundary.
Filtering by date only, ignoring the time component of the column
$orders = Order::whereDate('created_at', '>=', $start)
->whereDate('created_at', '<=', $end)
->get();
whereDate() compares only the date portion of a datetime column, ignoring its time entirely — a cleaner alternative to the startOfDay()/endOfDay() approach above when the intent is purely date-based filtering rather than a precise datetime range.
A date range from a request with default values
public function report(Request $request)
{
$start = $request->filled('start_date')
? Carbon::parse($request->start_date)->startOfDay()
: now()->subDays(30)->startOfDay();
$end = $request->filled('end_date')
? Carbon::parse($request->end_date)->endOfDay()
: now()->endOfDay();
$orders = Order::whereBetween('created_at', [$start, $end])->get();
}
Defaulting to the last 30 days when no explicit range is provided gives a report page a sensible result on first load, rather than either an empty result or every record ever created.
Grouping results by day within the range
$dailyTotals = Order::whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, SUM(total) as total')
->groupBy('date')
->orderBy('date')
->get();
A half-open range avoids end-of-day precision questions
For a date-only filter on a datetime column, another clean pattern is [start, nextDay): include timestamps at or after the start, and exclude timestamps at or after the day following the end date.
$start = Carbon::parse($request->start_date)->startOfDay();
$endExclusive = Carbon::parse($request->end_date)
->addDay()
->startOfDay();
$orders = Order::where('created_at', '>=', $start)
->where('created_at', '<', $endExclusive)
->get();
This remains correct regardless of whether the database column stores seconds or microseconds and avoids having to reason about the last representable instant of the day.
Validate the range before parsing it
User-supplied dates should be validated for format and ordering before they reach the query. A report that accepts an end date before the start date should return a validation error, not simply an empty result that looks like there were no orders.
$validated = $request->validate([
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
]);
Timezone boundaries belong to the user's calendar, then to UTC storage
If timestamps are stored in UTC but the report is for a local business day, parse the requested dates in that local timezone first and convert the boundaries to UTC before querying. Otherwise an order created near midnight can land on the wrong report day even though the SQL range itself is technically correct.
whereDate() is convenient, but ranges can be friendlier to indexes
whereDate() expresses calendar-date intent clearly. On a large table, however, applying a date function to the indexed timestamp column can limit how efficiently the database uses that index. A raw-column range using precomputed boundaries is often the better shape for high-volume reporting queries; measure with the database's query plan rather than assuming either form is universally faster.