How to handle PHP DateTime without going insane in 2025
PHP DateTime is a mess of gotchas, timezone traps, and deprecated classes. Here''s how I handle it cleanly in Laravel. No more insane debugging sessions.
How to handle PHP DateTime without going insane in 2025
PHP’s date and time handling has cost me more debugging hours than almost anything else in 12 years of development.
Not because it’s impossible. Because it’s deceptively familiar. You write new DateTime('now') and it works. You add three hours. It works. You deploy to a server with a different timezone configuration and suddenly your scheduled jobs are firing at 3 AM instead of midnight. Your timestamps are six hours off in production. An entire batch of time-sensitive records is corrupt.
This is the guide I wish I had in 2013 when I first hit these problems. It covers how to handle PHP DateTime without going insane, using modern practices, the right classes, and Laravel’s Carbon layer where appropriate.
For PHP and Laravel developers who want to build faster, try Laracopilot free to see how much of this boilerplate gets generated automatically in a new project.
Why PHP DateTime is such a landmine
Before the solutions, a quick look at what makes this hard.
PHP has evolved its date/time handling across three major approaches:
- Procedural functions (
date(),strtotime(),mktime()): the original approach, still everywhere, full of global state problems DateTimeclass (PHP 5.2+): object-oriented, but mutable by default, which causes bugsDateTimeImmutableclass (PHP 5.5+): the version you should actually be using
The mutable vs. immutable distinction alone has caused hundreds of bugs in codebases I’ve reviewed. Here’s the classic gotcha:
$start = new DateTime('2025-01-01');
$end = $start->modify('+30 days');
// You think $start is still Jan 1. It's not.
// $start and $end both point to Jan 31.
echo $start->format('Y-m-d'); // 2025-01-31 (!!!)
echo $end->format('Y-m-d'); // 2025-01-31
With DateTimeImmutable, modify() returns a new object instead of mutating the original. The original stays exactly as you declared it.
$start = new DateTimeImmutable('2025-01-01');
$end = $start->modify('+30 days');
echo $start->format('Y-m-d'); // 2025-01-01 (correct)
echo $end->format('Y-m-d'); // 2025-01-31 (correct)
Default to DateTimeImmutable in every new project. If you’re inheriting a codebase that uses mutable DateTime, add this to your refactoring backlog.
Setting up timezone correctly from the start
Most PHP DateTime bugs are timezone bugs. They look like logic bugs. They’re not.
Set PHP’s default timezone explicitly
In your php.ini or application bootstrap:
; php.ini
date.timezone = "UTC"
Or in PHP:
date_default_timezone_set('UTC');
Store everything in UTC. Display in the user’s local timezone. This is the rule. There are no exceptions worth taking.
Storing dates in a local timezone is the decision that corrupts your data when a server moves, when a client changes locations, or when daylight saving time shifts. UTC has no daylight saving. UTC never changes. Everything else is a display concern.
Laravel timezone configuration
In Laravel, set your timezone in config/app.php:
'timezone' => 'UTC',
Then for display, convert at the view layer using the user’s stored timezone preference:
$userTimezone = auth()->user()->timezone ?? 'UTC';
$displayTime = $event->starts_at->timezone($userTimezone);
If you’re building a multi-tenant SaaS on Laravel, Laracopilot generates the full timezone-aware user preference system out of the box when you specify it in your prompt.
The Carbon layer in Laravel: what to use and when
Carbon is Laravel’s date/time library, extending DateTimeImmutable. If you’re working in Laravel, Carbon is your primary interface. You don’t need to reach for raw DateTimeImmutable often.
Creating dates with Carbon
use Carbon\Carbon;
// Now in UTC
$now = Carbon::now();
// A specific date
$date = Carbon::parse('2025-06-15');
// From a timestamp
$fromTimestamp = Carbon::createFromTimestamp(1718000000);
// From a format
$fromFormat = Carbon::createFromFormat('d/m/Y', '15/06/2025');
Date arithmetic with Carbon
Carbon’s arithmetic methods are readable and predictable:
$deadline = Carbon::now()->addDays(30);
$lastMonth = Carbon::now()->subMonth();
$nextQuarter = Carbon::now()->addQuarters(1);
// Difference between dates
$start = Carbon::parse('2025-01-01');
$end = Carbon::parse('2025-03-31');
$days = $start->diffInDays($end); // 89
Comparing dates
$now = Carbon::now();
$trial_end = Carbon::parse($user->trial_ends_at);
if ($now->isAfter($trial_end)) {
// Trial expired
}
if ($trial_end->diffInDays($now) <= 3) {
// Expiring soon: send reminder
}
Avoid direct string comparison for dates. "2025-03-15" > "2025-02-28" works for ISO format but breaks with any other format and teaches bad habits. Use Carbon’s comparison methods.
The three most common PHP DateTime bugs (and how to fix them)
These three patterns account for at least 80% of the DateTime bugs I’ve fixed in client code.
Bug 1: Storing local time in the database
// Wrong: stores server's local time, not UTC
$event->scheduled_at = new DateTime('now');
$event->save();
// Right: always store UTC
$event->scheduled_at = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$event->save();
In Laravel with Carbon:
// Eloquent model: declare datetime casts
protected $casts = [
'scheduled_at' => 'datetime',
'published_at' => 'datetime',
];
// Carbon::now() in Laravel uses the configured timezone (set it to UTC)
$event->scheduled_at = Carbon::now('UTC');
Bug 2: Comparing timestamps across timezones
Marcus, a Laravel developer at a fintech startup in Berlin, sent me this bug in 2024. His transaction timestamps were shifting by two hours in certain reports. The problem: he was comparing a UTC timestamp from the database against a Carbon::now() that his team had accidentally configured in Europe/Berlin timezone.
Two objects, two timezones, one comparison. The result was off by exactly the offset between UTC and CET.
The fix:
// Always normalize to UTC before comparing
$dbTimestamp = Carbon::parse($record->created_at)->utc();
$now = Carbon::now('UTC');
if ($dbTimestamp->isBefore($now)) {
// This comparison is now reliable
}
Bug 3: Mutating DateTime in a loop
This one shows up in data processing scripts constantly:
// Wrong: $date mutates on every iteration with DateTime
$date = new DateTime('2025-01-01');
for ($i = 0; $i < 12; $i++) {
$months[] = $date->modify('+1 month'); // All references point to the same mutated object!
}
// All elements of $months are the same: 2026-01-01
Fix it with DateTimeImmutable:
// Right: each modify() returns a new object
$date = new DateTimeImmutable('2025-01-01');
for ($i = 0; $i < 12; $i++) {
$months[] = $date->modify('+'. $i. ' months'); // $date never changes
}
// Correct: $months contains Jan through Dec 2025
Or with Carbon:
$start = Carbon::parse('2025-01-01');
$months = collect(range(0, 11))->map(fn($i) => $start->copy()->addMonths($i));
Note the .copy() call. Carbon extends DateTime (mutable) not DateTimeImmutable, so you need to explicitly copy before modifying to avoid mutation bugs. This is Carbon’s one rough edge.
Formatting dates for display vs. storage
These are different concerns and should be handled at different layers.
Storage format: ISO 8601
Always store as ISO 8601 in your database:
-- MySQL column type
scheduled_at DATETIME DEFAULT NULL
-- The value stored
2025-06-15 14:30:00
Or with timezone offset if your database supports it (PostgreSQL’s TIMESTAMPTZ is preferable for multi-timezone applications):
2025-06-15T14:30:00Z
Display format: user-facing
Convert to the user’s timezone and their preferred format at the presentation layer. Never in the model. Never in the controller. At the view or the API serializer.
// API resource
public function toArray($request): array
{
$userTz = $request->user()?->timezone ?? 'UTC';
return [
'scheduled_at_utc' => $this->scheduled_at->toISOString(),
'scheduled_at_display' => $this->scheduled_at
->timezone($userTz)
->format('M j, Y g:i A'),
];
}
Expose the UTC value for programmatic use. Expose the formatted local value for display. Separation of concerns applies to time as much as anything else.
PHP DateTime with queues and scheduled jobs
Queued jobs serialize the data you pass them. If you pass a Carbon or DateTime object, it gets serialized to a string. When the job runs, that string gets deserialized.
The problem: if you serialize a local time and the job runs on a server in a different timezone, the deserialized time will be interpreted in that server’s timezone.
The rule: pass timestamps as UTC ISO strings or Unix timestamps. Not DateTime objects.
// Wrong: passes a Carbon object (timezone-dependent deserialization)
dispatch(new SendReminderJob($event->scheduled_at));
// Right: pass the UTC timestamp string
dispatch(new SendReminderJob($event->scheduled_at->toISOString()));
// In the job:
public function handle(): void
{
$scheduledAt = Carbon::parse($this->scheduledAt)->utc();
// Now you're always working in UTC
}
This pattern has saved three different client projects from timezone-related data corruption that would have been nearly impossible to trace after the fact.
Testing date-dependent code
Priya runs a SaaS with a trial-expiry system built on Laravel. She came to me because her tests were flaky: they passed on Mondays, failed on Fridays, passed again the following week. The culprit was code that used Carbon::now() inside business logic. The tests weren’t controlling for time.
The fix: Carbon’s time travel feature.
// In your test setup
Carbon::setTestNow(Carbon::parse('2025-06-01 12:00:00'));
// Your code under test
$user = User::factory()->create(['trial_ends_at' => Carbon::now()->addDays(3)]);
// Assert the trial-expiry logic
$this->assertTrue($user->isTrialExpiringSoon()); // checks if < 5 days remain
// Clean up after the test
Carbon::setTestNow();
With Pest (which I use on every Laravel project):
it('sends a reminder when trial expires in 3 days', function () {
Carbon::setTestNow('2025-06-01 12:00:00');
$user = User::factory()->create([
'trial_ends_at' => Carbon::now()->addDays(3),
]);
$this->artisan('trial:send-reminders');
Mail::assertQueued(TrialExpiringMail::class, fn($mail) => $mail->hasTo($user->email));
Carbon::setTestNow();
});
Never let now() run uncontrolled in a test. Always mock time. This is non-negotiable for reliable test suites.
Quick reference: PHP DateTime cheat sheet
Always use:
DateTimeImmutableoverDateTimein plain PHPCarbon::now('UTC')in Laravel- UTC for all stored values
- ISO 8601 for storage format (
Y-m-d H:i:sorc) ->copy()before mutating a Carbon instance in a loop
Never use:
date()andstrtotime()in new code (procedural functions, global state)- Local timezones for stored values
- String comparison for date comparisons
- Unserialized
DateTimeobjects in queued jobs
Test with:
Carbon::setTestNow()for any time-dependent logic- Explicit timezone assertions in integration tests
Conclusion
Handling PHP DateTime without going insane comes down to three habits: use immutable objects, store everything in UTC, and mock time in your tests.
The bugs that haunt PHP DateTime code are almost never mysterious. They’re the same five patterns, repeated in different codebases, by developers who inherited code that didn’t follow these rules and didn’t have time to fix it before it bit them.
If you’re starting a new Laravel project and want the date handling, timezone configuration, and Eloquent casts set up correctly from the first commit, try Laracopilot free. It generates the full application structure with proper timezone-aware defaults, so you’re not manually configuring this for the 40th time.
For teams that need senior Laravel engineers who’ve already internalized these patterns and won’t ship timezone bugs into production, hire through Devlyn.ai. Senior-only, no juniors learning on your codebase.
And if you want more PHP and Laravel deep-dives like this one, subscribe to my newsletter for weekly posts from someone who’s actually building with this stack right now.