PHP DateTime: Modern Best Practices for Timestamps and Timezones

Master PHP 8+ datetime handling with DateTime, DateTimeImmutable, Carbon library, timezone conversions, DST handling, and database integration. Learn modern best practices for production PHP applications.

PHP DateTime: Modern Best Practices

Ever struggled with PHP's datetime handling? You're not alone. PHP's evolved significantly since the early days, and today's DateTime class (plus the amazing Carbon library) makes working with timestamps actually enjoyable. Let's dive into everything from basic operations to the advanced timezone magic you'll need in production.

The DateTime Class

PHP's native DateTime class (introduced in PHP 5.2, enhanced in PHP 8+) offers object-oriented datetime handling.

Creating DateTime Objects

<?php

// Current time
$now = new DateTime();
echo $now->format('Y-m-d H:i:s');  // 2024-01-15 19:00:00

// Current time in UTC
$utcNow = new DateTime('now', new DateTimeZone('UTC'));

// From specific date
$date = new DateTime('2024-01-15 19:00:00');

// From Unix timestamp
$timestamp = 1705341600;
$fromTimestamp = new DateTime("@$timestamp");
echo $fromTimestamp->format('Y-m-d H:i:s');  // 2024-01-15 19:00:00

// From format string
$formatted = DateTime::createFromFormat('Y-m-d H:i:s', '2024-01-15 19:00:00');

// Parse various formats
$dates = [
    new DateTime('2024-01-15'),
    new DateTime('January 15, 2024'),
    new DateTime('+1 day'),
    new DateTime('next Monday'),
    new DateTime('first day of next month'),
];

foreach ($dates as $date) {
    echo $date->format('Y-m-d') . PHP_EOL;
}

Converting to Timestamps

<?php

$date = new DateTime('2024-01-15 19:00:00');

// To Unix timestamp
$timestamp = $date->getTimestamp();
echo $timestamp;  // 1705341600

// To formatted string
echo $date->format('Y-m-d H:i:s');     // 2024-01-15 19:00:00
echo $date->format('c');                 // 2024-01-15T19:00:00+00:00 (ISO 8601)
echo $date->format(DateTime::ATOM);      // 2024-01-15T19:00:00+00:00
echo $date->format(DateTime::RFC3339);   // 2024-01-15T19:00:00+00:00

// Custom formats
echo $date->format('F j, Y g:i A');     // January 15, 2024 7:00 PM
echo $date->format('l, F j, Y');        // Monday, January 15, 2024

Format Characters Reference

<?php

/*
Date Formats:
Y = Year (4 digits)            → 2024
y = Year (2 digits)            → 24
m = Month (01-12)              → 01
n = Month (1-12)               → 1
M = Month short name           → Jan
F = Month full name            → January
d = Day (01-31)                → 15
j = Day (1-31)                 → 15
l = Day name                   → Monday
D = Day short name             → Mon

Time Formats:
H = Hour 24h (00-23)           → 19
h = Hour 12h (01-12)           → 07
i = Minute (00-59)             → 00
s = Second (00-59)             → 00
u = Microseconds               → 000000
A = AM/PM                      → PM
a = am/pm                      → pm

Timezone Formats:
e = Timezone identifier        → America/New_York
T = Timezone abbreviation      → EST
P = UTC offset                 → +05:00
O = UTC offset (no colon)      → +0500
Z = Offset in seconds          → 18000

Special:
c = ISO 8601                   → 2024-01-15T19:00:00+00:00
r = RFC 2822                   → Mon, 15 Jan 2024 19:00:00 +0000
U = Unix timestamp             → 1705341600
*/

$date = new DateTime('2024-01-15 19:00:00', new DateTimeZone('America/New_York'));

echo "ISO 8601: " . $date->format('c') . PHP_EOL;
echo "RFC 2822: " . $date->format('r') . PHP_EOL;
echo "Custom:   " . $date->format('l, F j, Y \a\t g:i A T') . PHP_EOL;
// Output: Monday, January 15, 2024 at 7:00 PM EST

DateTime vs. DateTimeImmutable

Here's where things get interesting. PHP 5.5+ gave us DateTimeImmutable, and honestly? It's a game-changer for avoiding those sneaky bugs that come from accidentally modifying dates.

The Mutability Problem (And Why It'll Bite You)

<?php

// ❌ DateTime is mutable (dangerous!)
$date = new DateTime('2024-01-15');
$tomorrow = $date->modify('+1 day');

// Both variables now point to the same modified date!
echo $date->format('Y-m-d');      // 2024-01-16
echo $tomorrow->format('Y-m-d');  // 2024-01-16

// ✅ DateTimeImmutable creates new instances
$date = new DateTimeImmutable('2024-01-15');
$tomorrow = $date->modify('+1 day');

// Original remains unchanged
echo $date->format('Y-m-d');      // 2024-01-15
echo $tomorrow->format('Y-m-d');  // 2024-01-16

Best Practice: Use DateTimeImmutable

<?php

// ✅ RECOMMENDED: Use DateTimeImmutable everywhere
class Event
{
    private DateTimeImmutable $scheduledAt;

    public function __construct(DateTimeImmutable $scheduledAt)
    {
        $this->scheduledAt = $scheduledAt;
    }

    public function reschedule(string $modifier): self
    {
        // Returns new Event with modified date (original unchanged)
        return new self($this->scheduledAt->modify($modifier));
    }

    public function getScheduledAt(): DateTimeImmutable
    {
        return $this->scheduledAt;
    }
}

// Usage
$event = new Event(new DateTimeImmutable('2024-01-15 19:00:00'));
$rescheduled = $event->reschedule('+1 week');

echo $event->getScheduledAt()->format('Y-m-d');       // 2024-01-15
echo $rescheduled->getScheduledAt()->format('Y-m-d'); // 2024-01-22

Timezone Handling

DateTimeZone Class

<?php

// Create timezone object
$utc = new DateTimeZone('UTC');
$nyc = new DateTimeZone('America/New_York');
$tokyo = new DateTimeZone('Asia/Tokyo');

// Current time in different timezones
$utcTime = new DateTimeImmutable('now', $utc);
$nycTime = new DateTimeImmutable('now', $nyc);
$tokyoTime = new DateTimeImmutable('now', $tokyo);

echo "UTC:   " . $utcTime->format('Y-m-d H:i:s T') . PHP_EOL;
echo "NYC:   " . $nycTime->format('Y-m-d H:i:s T') . PHP_EOL;
echo "Tokyo: " . $tokyoTime->format('Y-m-d H:i:s T') . PHP_EOL;

// List all available timezones
$timezones = DateTimeZone::listIdentifiers();
echo "Total timezones: " . count($timezones) . PHP_EOL;

// Filter timezones by region
$americanZones = DateTimeZone::listIdentifiers(DateTimeZone::AMERICA);
$europeanZones = DateTimeZone::listIdentifiers(DateTimeZone::EUROPE);
$asianZones = DateTimeZone::listIdentifiers(DateTimeZone::ASIA);

// Get timezone info
$tz = new DateTimeZone('America/New_York');
$transitions = $tz->getTransitions();
$location = $tz->getLocation();

print_r([
    'country_code' => $location['country_code'],
    'latitude' => $location['latitude'],
    'longitude' => $location['longitude'],
]);

Converting Between Timezones

<?php

// Create datetime in one timezone
$nycTime = new DateTimeImmutable(
    '2024-01-15 14:00:00',
    new DateTimeZone('America/New_York')
);

// Convert to different timezones
$utcTime = $nycTime->setTimezone(new DateTimeZone('UTC'));
$tokyoTime = $nycTime->setTimezone(new DateTimeZone('Asia/Tokyo'));
$londonTime = $nycTime->setTimezone(new DateTimeZone('Europe/London'));

echo "NYC:    " . $nycTime->format('Y-m-d H:i:s P') . PHP_EOL;
echo "UTC:    " . $utcTime->format('Y-m-d H:i:s P') . PHP_EOL;
echo "Tokyo:  " . $tokyoTime->format('Y-m-d H:i:s P') . PHP_EOL;
echo "London: " . $londonTime->format('Y-m-d H:i:s P') . PHP_EOL;

// Output:
// NYC:    2024-01-15 14:00:00 -05:00
// UTC:    2024-01-15 19:00:00 +00:00
// Tokyo:  2024-01-16 04:00:00 +09:00
// London: 2024-01-15 19:00:00 +00:00

Default Timezone Configuration

<?php

// Set default timezone (php.ini: date.timezone)
date_default_timezone_set('UTC');

// Get current default
$defaultTz = date_default_timezone_get();
echo "Default timezone: $defaultTz" . PHP_EOL;

// Best practice: Always set to UTC
date_default_timezone_set('UTC');

// Then convert to user's timezone when displaying
function displayInUserTimezone(DateTimeImmutable $dt, string $userTz): string
{
    $userTime = $dt->setTimezone(new DateTimeZone($userTz));
    return $userTime->format('Y-m-d H:i:s T');
}

$utcNow = new DateTimeImmutable('now', new DateTimeZone('UTC'));
echo displayInUserTimezone($utcNow, 'America/New_York');

DateTime Arithmetic

Modify Method

<?php

$date = new DateTimeImmutable('2024-01-15 19:00:00');

// Add/subtract time
$tomorrow = $date->modify('+1 day');
$nextWeek = $date->modify('+1 week');
$nextMonth = $date->modify('+1 month');
$in2Hours = $date->modify('+2 hours');

// Complex modifications
$complex = $date->modify('+1 month +2 weeks +3 days');

// Relative formats
$dates = [
    $date->modify('next Monday'),
    $date->modify('last Friday'),
    $date->modify('first day of next month'),
    $date->modify('last day of this month'),
    $date->modify('first Monday of next month'),
];

foreach ($dates as $d) {
    echo $d->format('Y-m-d l') . PHP_EOL;
}

// Subtract time
$yesterday = $date->modify('-1 day');
$lastWeek = $date->modify('-1 week');

DateInterval Class

<?php

// Create intervals
$oneDay = new DateInterval('P1D');      // Period: 1 Day
$oneWeek = new DateInterval('P1W');     // Period: 1 Week
$oneMonth = new DateInterval('P1M');    // Period: 1 Month
$oneYear = new DateInterval('P1Y');     // Period: 1 Year
$twoHours = new DateInterval('PT2H');   // Period: Time 2 Hours
$complex = new DateInterval('P1Y2M3DT4H5M6S');  // 1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds

/*
DateInterval Format:
P = Period designator
T = Time designator (separates date from time)

Date parts:
Y = Years
M = Months (before T)
D = Days
W = Weeks

Time parts (after T):
H = Hours
M = Minutes (after T)
S = Seconds
*/

// Add interval
$date = new DateTimeImmutable('2024-01-15');
$future = $date->add($oneMonth);
echo $future->format('Y-m-d');  // 2024-02-15

// Subtract interval
$past = $date->sub($oneWeek);
echo $past->format('Y-m-d');  // 2024-01-08

// Create interval from string
$interval = DateInterval::createFromDateString('3 days 2 hours');
$result = $date->add($interval);

Calculating Differences

<?php

$start = new DateTimeImmutable('2024-01-15 19:00:00');
$end = new DateTimeImmutable('2024-01-20 21:30:00');

// Get difference
$diff = $start->diff($end);

// Access components
echo "Years: " . $diff->y . PHP_EOL;        // 0
echo "Months: " . $diff->m . PHP_EOL;       // 0
echo "Days: " . $diff->d . PHP_EOL;         // 5
echo "Hours: " . $diff->h . PHP_EOL;        // 2
echo "Minutes: " . $diff->i . PHP_EOL;      // 30
echo "Seconds: " . $diff->s . PHP_EOL;      // 0

// Total days (includes fractional part)
echo "Total days: " . $diff->days . PHP_EOL;  // 5

// Formatted output
echo $diff->format('%d days, %h hours, %i minutes') . PHP_EOL;
// Output: 5 days, 2 hours, 30 minutes

// Check if negative (past)
if ($diff->invert) {
    echo "Date is in the past";
}

// Practical example: Age calculation
function calculateAge(DateTimeImmutable $birthdate): int
{
    $now = new DateTimeImmutable();
    $age = $birthdate->diff($now);
    return $age->y;
}

$birthdate = new DateTimeImmutable('1990-05-15');
echo "Age: " . calculateAge($birthdate) . " years";

Carbon: The Better DateTime Library (Seriously, You'll Love It)

Let's be real—Carbon isn't just "better," it's like DateTime went to finishing school. The fluent API? Chef's kiss. The human-readable syntax? Exactly what we always wanted.

Installation

composer require nesbot/carbon

Basic Usage

<?php

use Carbon\Carbon;

// Current time (timezone-aware by default)
$now = Carbon::now();
$utcNow = Carbon::now('UTC');
$nycNow = Carbon::now('America/New_York');

echo $now . PHP_EOL;  // 2024-01-15 19:00:00

// Create from various sources
$date = Carbon::create(2024, 1, 15, 19, 0, 0);
$fromTimestamp = Carbon::createFromTimestamp(1705341600);
$fromFormat = Carbon::createFromFormat('Y-m-d H:i:s', '2024-01-15 19:00:00');
$fromString = Carbon::parse('2024-01-15 19:00:00');

// Parsing flexibility
$dates = [
    Carbon::parse('2024-01-15'),
    Carbon::parse('January 15, 2024'),
    Carbon::parse('15-Jan-2024'),
    Carbon::parse('2024-01-15T19:00:00Z'),
];

// Today, tomorrow, yesterday
$today = Carbon::today();           // 2024-01-15 00:00:00
$tomorrow = Carbon::tomorrow();     // 2024-01-16 00:00:00
$yesterday = Carbon::yesterday();   // 2024-01-14 00:00:00

Fluent Modifiers

<?php

use Carbon\Carbon;

$date = Carbon::create(2024, 1, 15, 19, 0, 0);

// Add time
$tomorrow = $date->copy()->addDay();
$nextWeek = $date->copy()->addWeek();
$nextMonth = $date->copy()->addMonth();
$in2Hours = $date->copy()->addHours(2);

// Subtract time
$yesterday = $date->copy()->subDay();
$lastWeek = $date->copy()->subWeek();

// Chaining
$future = $date->copy()
    ->addDays(5)
    ->addHours(3)
    ->addMinutes(30);

// Set specific components
$modified = $date->copy()
    ->setYear(2025)
    ->setMonth(6)
    ->setDay(15)
    ->setTime(12, 0, 0);

// Start/end of period
$startOfDay = $date->copy()->startOfDay();      // 2024-01-15 00:00:00
$endOfDay = $date->copy()->endOfDay();          // 2024-01-15 23:59:59
$startOfMonth = $date->copy()->startOfMonth();  // 2024-01-01 00:00:00
$endOfMonth = $date->copy()->endOfMonth();      // 2024-01-31 23:59:59
$startOfYear = $date->copy()->startOfYear();    // 2024-01-01 00:00:00

echo "Start of month: " . $startOfMonth . PHP_EOL;
echo "End of month: " . $endOfMonth . PHP_EOL;

Human-Readable Differences

<?php

use Carbon\Carbon;

$now = Carbon::now();

// Past dates
$past = Carbon::now()->subDays(5);
echo $past->diffForHumans();  // "5 days ago"

$recent = Carbon::now()->subMinutes(30);
echo $recent->diffForHumans();  // "30 minutes ago"

// Future dates
$future = Carbon::now()->addDays(3);
echo $future->diffForHumans();  // "3 days from now"

// Relative to another date
$date1 = Carbon::parse('2024-01-15');
$date2 = Carbon::parse('2024-01-20');
echo $date1->diffForHumans($date2);  // "5 days before"

// Absolute differences
$diff = $now->diffInDays($past);      // 5
$diff = $now->diffInHours($recent);   // 0 (less than 1 hour)
$diff = $now->diffInMinutes($recent); // 30
$diff = $now->diffInSeconds($recent); // 1800

Comparison Methods

<?php

use Carbon\Carbon;

$date = Carbon::parse('2024-01-15');
$now = Carbon::now();

// Comparison operators
$isBefore = $date->lt($now);        // less than (<)
$isBeforeOrEqual = $date->lte($now); // less than or equal (<=)
$isAfter = $date->gt($now);         // greater than (>)
$isAfterOrEqual = $date->gte($now); // greater than or equal (>=)
$isEqual = $date->eq($now);         // equal (==)
$isNotEqual = $date->ne($now);      // not equal (!=)

// Named methods
$isPast = $date->isPast();
$isFuture = $date->isFuture();
$isToday = $date->isToday();
$isYesterday = $date->isYesterday();
$isTomorrow = $date->isTomorrow();

// Day of week checks
$isMonday = $date->isMonday();
$isWeekend = $date->isWeekend();
$isWeekday = $date->isWeekday();

// Range checks
$inRange = $date->between($start, $end);
$inRangeExclusive = $date->between($start, $end, false);

// Practical example
if ($now->isWeekend() && $now->hour >= 9 && $now->hour < 17) {
    echo "It's the weekend during business hours!";
}

Timezone Conversion with Carbon

<?php

use Carbon\Carbon;

// Create in one timezone
$nycTime = Carbon::create(2024, 1, 15, 14, 0, 0, 'America/New_York');

// Convert to different timezones
$utcTime = $nycTime->copy()->setTimezone('UTC');
$tokyoTime = $nycTime->copy()->setTimezone('Asia/Tokyo');
$londonTime = $nycTime->copy()->setTimezone('Europe/London');

echo "NYC:    " . $nycTime->format('Y-m-d H:i:s T') . PHP_EOL;
echo "UTC:    " . $utcTime->format('Y-m-d H:i:s T') . PHP_EOL;
echo "Tokyo:  " . $tokyoTime->format('Y-m-d H:i:s T') . PHP_EOL;
echo "London: " . $londonTime->format('Y-m-d H:i:s T') . PHP_EOL;

// Shorthand methods
$utc = $nycTime->copy()->utc();  // Alias for setTimezone('UTC')
$local = $utcTime->copy()->local();  // Convert to default timezone

Handling DST Transitions

Ever had a recurring event that mysteriously jumps an hour? Welcome to DST. But don't worry—we've got patterns that'll handle these transitions gracefully.

DST-Aware Arithmetic

<?php

use Carbon\Carbon;

// Spring forward: March 10, 2024, 2:00 AM → 3:00 AM in New York
// Attempting to create 2:30 AM (doesn't exist)
$tz = new DateTimeZone('America/New_York');

try {
    $nonExistent = new DateTimeImmutable('2024-03-10 02:30:00', $tz);
    echo $nonExistent->format('Y-m-d H:i:s T') . PHP_EOL;
    // Automatically adjusted to 03:30:00 EDT
} catch (Exception $e) {
    echo "Error: " . $e->getMessage();
}

// With Carbon
$dstTransition = Carbon::create(2024, 3, 10, 2, 30, 0, 'America/New_York');
echo $dstTransition->format('Y-m-d H:i:s T') . PHP_EOL;
// 2024-03-10 03:30:00 EDT (automatically adjusted)

// Fall back: November 3, 2024, 2:00 AM → 1:00 AM
// 1:30 AM occurs twice
$ambiguous = Carbon::create(2024, 11, 3, 1, 30, 0, 'America/New_York');
echo $ambiguous->format('Y-m-d H:i:s T') . PHP_EOL;
// 2024-11-03 01:30:00 EDT (first occurrence)

// Check if DST is active
$date = Carbon::create(2024, 7, 15, 12, 0, 0, 'America/New_York');
echo "DST active: " . ($date->dst ? 'Yes' : 'No') . PHP_EOL;
// DST active: Yes

Recurring Events and DST

<?php

use Carbon\Carbon;

function scheduleRecurringEvent(Carbon $start, int $occurrences = 5): array
{
    $events = [];
    $current = $start->copy();

    for ($i = 0; $i < $occurrences; $i++) {
        $events[] = [
            'occurrence' => $i + 1,
            'utc' => $current->copy()->utc()->toIso8601String(),
            'local' => $current->toIso8601String(),
            'offset' => $current->format('P'),
            'dst' => $current->dst,
        ];

        // Add 1 week (handles DST automatically)
        $current->addWeek();
    }

    return $events;
}

// Example spanning DST transition
$start = Carbon::create(2024, 3, 3, 2, 0, 0, 'America/New_York');
$events = scheduleRecurringEvent($start, 3);

foreach ($events as $event) {
    echo sprintf(
        "Week %d: %s (DST: %s, Offset: %s)\n",
        $event['occurrence'],
        $event['local'],
        $event['dst'] ? 'Yes' : 'No',
        $event['offset']
    );
}

// Output:
// Week 1: 2024-03-03T02:00:00-05:00 (DST: No, Offset: -05:00)
// Week 2: 2024-03-10T03:00:00-04:00 (DST: Yes, Offset: -04:00)  ← Adjusted!
// Week 3: 2024-03-17T02:00:00-04:00 (DST: Yes, Offset: -04:00)

Database Integration

Storing Timestamps

<?php

use Carbon\Carbon;

// PDO with MySQL/PostgreSQL
class EventRepository
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    // Store as UTC timestamp
    public function save(string $title, Carbon $scheduledAt): int
    {
        $stmt = $this->pdo->prepare("
            INSERT INTO events (title, scheduled_at, timezone, created_at)
            VALUES (:title, :scheduled_at, :timezone, :created_at)
        ");

        $stmt->execute([
            'title' => $title,
            'scheduled_at' => $scheduledAt->copy()->utc()->toDateTimeString(),
            'timezone' => $scheduledAt->timezone->getName(),
            'created_at' => Carbon::now()->utc()->toDateTimeString(),
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    // Retrieve and convert to timezone
    public function find(int $id, string $timezone = 'UTC'): ?array
    {
        $stmt = $this->pdo->prepare("
            SELECT id, title, scheduled_at, timezone, created_at
            FROM events
            WHERE id = :id
        ");

        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$row) {
            return null;
        }

        // Parse UTC timestamp and convert to requested timezone
        $scheduledAt = Carbon::parse($row['scheduled_at'], 'UTC')
            ->setTimezone($timezone);

        $createdAt = Carbon::parse($row['created_at'], 'UTC')
            ->setTimezone($timezone);

        return [
            'id' => $row['id'],
            'title' => $row['title'],
            'scheduled_at' => $scheduledAt,
            'original_timezone' => $row['timezone'],
            'created_at' => $createdAt,
        ];
    }

    // Query by date range
    public function findInRange(Carbon $start, Carbon $end): array
    {
        $stmt = $this->pdo->prepare("
            SELECT id, title, scheduled_at, timezone
            FROM events
            WHERE scheduled_at BETWEEN :start AND :end
            ORDER BY scheduled_at ASC
        ");

        $stmt->execute([
            'start' => $start->copy()->utc()->toDateTimeString(),
            'end' => $end->copy()->utc()->toDateTimeString(),
        ]);

        return array_map(function ($row) {
            return [
                'id' => $row['id'],
                'title' => $row['title'],
                'scheduled_at' => Carbon::parse($row['scheduled_at'], 'UTC'),
                'original_timezone' => $row['timezone'],
            ];
        }, $stmt->fetchAll(PDO::FETCH_ASSOC));
    }
}

// Usage
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'password');
$repo = new EventRepository($pdo);

// Save event
$eventId = $repo->save(
    'Team Meeting',
    Carbon::create(2024, 1, 15, 14, 0, 0, 'America/New_York')
);

// Retrieve in user's timezone
$event = $repo->find($eventId, 'America/Los_Angeles');
echo $event['scheduled_at']->format('Y-m-d H:i:s T');

Laravel Eloquent Integration

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;

class Event extends Model
{
    protected $fillable = ['title', 'scheduled_at', 'timezone'];

    // Automatically cast to Carbon instances
    protected $casts = [
        'scheduled_at' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    // Accessor: Convert to user's timezone
    public function getScheduledAtInTimezoneAttribute(): Carbon
    {
        return $this->scheduled_at
            ->copy()
            ->setTimezone($this->timezone ?? 'UTC');
    }

    // Mutator: Store as UTC
    public function setScheduledAtAttribute($value): void
    {
        $this->attributes['scheduled_at'] = Carbon::parse($value)
            ->utc()
            ->toDateTimeString();
    }

    // Scope: Events in date range
    public function scopeInRange($query, Carbon $start, Carbon $end)
    {
        return $query->whereBetween('scheduled_at', [
            $start->copy()->utc(),
            $end->copy()->utc(),
        ]);
    }

    // Scope: Upcoming events
    public function scopeUpcoming($query)
    {
        return $query->where('scheduled_at', '>', Carbon::now()->utc());
    }
}

// Usage
$event = Event::create([
    'title' => 'Team Meeting',
    'scheduled_at' => Carbon::create(2024, 1, 15, 14, 0, 0, 'America/New_York'),
    'timezone' => 'America/New_York',
]);

// Retrieve with timezone conversion
echo $event->scheduled_at_in_timezone->format('Y-m-d H:i:s T');

// Query upcoming events
$upcoming = Event::upcoming()->get();

Testing Time-Dependent Code

Carbon's Test Helpers

<?php

use Carbon\Carbon;
use PHPUnit\Framework\TestCase;

class SessionTest extends TestCase
{
    public function testSessionExpiry(): void
    {
        // Freeze time to specific moment
        Carbon::setTestNow(Carbon::create(2024, 1, 15, 19, 0, 0));

        $session = new Session(expiresInSeconds: 3600);
        $this->assertTrue($session->isValid());

        // Travel forward 59 minutes (still valid)
        Carbon::setTestNow(Carbon::create(2024, 1, 15, 19, 59, 0));
        $this->assertTrue($session->isValid());

        // Travel forward 61 minutes (expired)
        Carbon::setTestNow(Carbon::create(2024, 1, 15, 20, 1, 0));
        $this->assertFalse($session->isValid());

        // Reset time
        Carbon::setTestNow();
    }

    public function testDSTTransition(): void
    {
        // Before DST (EST)
        Carbon::setTestNow(Carbon::create(2024, 3, 9, 12, 0, 0, 'America/New_York'));
        $now = Carbon::now('America/New_York');
        $this->assertEquals(-5, $now->offsetHours);
        $this->assertFalse($now->dst);

        // After DST (EDT)
        Carbon::setTestNow(Carbon::create(2024, 3, 11, 12, 0, 0, 'America/New_York'));
        $now = Carbon::now('America/New_York');
        $this->assertEquals(-4, $now->offsetHours);
        $this->assertTrue($now->dst);

        Carbon::setTestNow();
    }

    protected function tearDown(): void
    {
        // Always reset test time after each test
        Carbon::setTestNow();
        parent::tearDown();
    }
}

Mocking DateTime for Testing

<?php

use PHPUnit\Framework\TestCase;

class DateTimeTest extends TestCase
{
    public function testWithMockedTime(): void
    {
        $fixedTime = new DateTimeImmutable('2024-01-15 19:00:00', new DateTimeZone('UTC'));

        // Inject clock into class under test
        $service = new TimeService($fixedTime);

        $result = $service->isBusinessHours();
        $this->assertTrue($result);
    }
}

// Service with injectable time
class TimeService
{
    private DateTimeImmutable $now;

    public function __construct(?DateTimeImmutable $now = null)
    {
        $this->now = $now ?? new DateTimeImmutable();
    }

    public function isBusinessHours(): bool
    {
        $hour = (int) $this->now->format('H');
        return $hour >= 9 && $hour < 17;
    }

    public function getCurrentTime(): DateTimeImmutable
    {
        return $this->now;
    }
}

Best Practices

1. Always Use UTC for Storage

<?php

use Carbon\Carbon;

// ✅ CORRECT: Store as UTC
class EventService
{
    public function createEvent(string $title, string $datetime, string $timezone): void
    {
        $scheduledAt = Carbon::parse($datetime, $timezone)->utc();

        // Store UTC timestamp
        DB::table('events')->insert([
            'title' => $title,
            'scheduled_at' => $scheduledAt->toDateTimeString(),
            'timezone' => $timezone,
        ]);
    }

    public function displayEvent(int $id, string $userTimezone): string
    {
        $event = DB::table('events')->find($id);

        // Convert to user's timezone for display
        $scheduledAt = Carbon::parse($event->scheduled_at, 'UTC')
            ->setTimezone($userTimezone);

        return $scheduledAt->format('Y-m-d H:i:s T');
    }
}

2. Use DateTimeImmutable or Carbon

<?php

use Carbon\Carbon;

// ❌ BAD: Mutable DateTime
class Event
{
    private DateTime $scheduledAt;

    public function reschedule(string $modifier): void
    {
        $this->scheduledAt->modify($modifier);  // Mutates original!
    }
}

// ✅ GOOD: Immutable
class Event
{
    private DateTimeImmutable $scheduledAt;

    public function reschedule(string $modifier): self
    {
        $newScheduledAt = $this->scheduledAt->modify($modifier);
        return new self($newScheduledAt);  // Returns new instance
    }
}

// ✅ BEST: Carbon with copy()
class Event
{
    private Carbon $scheduledAt;

    public function reschedule(string $modifier): self
    {
        $newScheduledAt = $this->scheduledAt->copy()->modify($modifier);
        return new self($newScheduledAt);
    }
}

3. Validate DateTime Inputs

<?php

use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;

function parseDateTime(mixed $input, ?string $timezone = null): Carbon
{
    try {
        // Handle various input types
        if ($input instanceof Carbon) {
            return $timezone ? $input->copy()->setTimezone($timezone) : $input->copy();
        }

        if ($input instanceof DateTimeInterface) {
            $carbon = Carbon::instance($input);
            return $timezone ? $carbon->setTimezone($timezone) : $carbon;
        }

        if (is_numeric($input)) {
            // Unix timestamp
            if ($input < 0) {
                throw new InvalidArgumentException('Timestamp cannot be negative');
            }

            // Check if milliseconds (larger than year 2286)
            if ($input > 10000000000) {
                $input = $input / 1000;
            }

            return Carbon::createFromTimestamp($input, $timezone ?? 'UTC');
        }

        if (is_string($input)) {
            return Carbon::parse($input, $timezone ?? 'UTC');
        }

        throw new InvalidArgumentException('Invalid datetime input type: ' . gettype($input));

    } catch (InvalidFormatException $e) {
        throw new InvalidArgumentException('Invalid datetime format: ' . $e->getMessage());
    }
}

// Usage
$dates = [
    parseDateTime('2024-01-15 19:00:00'),
    parseDateTime(1705341600),
    parseDateTime(new DateTime('2024-01-15')),
    parseDateTime(Carbon::now()),
];

4. Handle Timezones Explicitly

<?php

use Carbon\Carbon;

// ❌ BAD: Implicit system timezone
$date = Carbon::now();

// ✅ GOOD: Explicit timezone
$date = Carbon::now('UTC');
$date = Carbon::now('America/New_York');

// ✅ BEST: Configuration-driven
class DateTimeFactory
{
    private string $defaultTimezone;

    public function __construct(string $defaultTimezone = 'UTC')
    {
        $this->defaultTimezone = $defaultTimezone;
    }

    public function now(?string $timezone = null): Carbon
    {
        return Carbon::now($timezone ?? $this->defaultTimezone);
    }

    public function parse(string $datetime, ?string $timezone = null): Carbon
    {
        return Carbon::parse($datetime, $timezone ?? $this->defaultTimezone);
    }
}

Common Pitfalls

1. Mutating DateTime Objects

<?php

// ❌ WRONG
$date = new DateTime('2024-01-15');
$tomorrow = $date->modify('+1 day');

// Both variables now reference the modified date!
echo $date->format('Y-m-d');      // 2024-01-16
echo $tomorrow->format('Y-m-d');  // 2024-01-16

// ✅ CORRECT
$date = new DateTimeImmutable('2024-01-15');
$tomorrow = $date->modify('+1 day');

echo $date->format('Y-m-d');      // 2024-01-15
echo $tomorrow->format('Y-m-d');  // 2024-01-16

2. Ignoring Timezone Context

<?php

// ❌ WRONG: Ambiguous
$date = new DateTime('2024-01-15 14:00:00');  // Which timezone?

// ✅ CORRECT: Explicit timezone
$date = new DateTime('2024-01-15 14:00:00', new DateTimeZone('America/New_York'));

// ✅ BETTER: Use Carbon
use Carbon\Carbon;
$date = Carbon::create(2024, 1, 15, 14, 0, 0, 'America/New_York');

3. Forgetting to Copy Carbon Objects

<?php

use Carbon\Carbon;

// ❌ WRONG
$date = Carbon::now();
$tomorrow = $date->addDay();  // Modifies $date!

echo $date->toDateString();      // 2024-01-16
echo $tomorrow->toDateString();  // 2024-01-16

// ✅ CORRECT
$date = Carbon::now();
$tomorrow = $date->copy()->addDay();  // Creates new instance

echo $date->toDateString();      // 2024-01-15
echo $tomorrow->toDateString();  // 2024-01-16

Performance Tips

Caching Timezone Objects

<?php

class TimezoneCache
{
    private static array $cache = [];

    public static function get(string $timezone): DateTimeZone
    {
        if (!isset(self::$cache[$timezone])) {
            self::$cache[$timezone] = new DateTimeZone($timezone);
        }

        return self::$cache[$timezone];
    }
}

// Usage
$tz = TimezoneCache::get('America/New_York');
$date = new DateTimeImmutable('now', $tz);

Batch Conversions

<?php

use Carbon\Carbon;

function convertTimestampsBatch(array $timestamps, string $targetTimezone): array
{
    // Create timezone object once
    $tz = new DateTimeZone($targetTimezone);

    return array_map(
        fn($ts) => Carbon::createFromTimestamp($ts, $tz),
        $timestamps
    );
}

$timestamps = [1705341600, 1705428000, 1705514400];
$converted = convertTimestampsBatch($timestamps, 'America/New_York');

Wrapping Up: Your PHP DateTime Journey

Look, I'll be honest with you. When I first started working with PHP datetimes, I made every mistake in this guide. Mutating DateTimes? Check. Ignoring timezones? Yep. Forgetting about DST? Oh, absolutely.

But here's what I've learned over the years:

Your DateTime Toolkit:

  • DateTimeImmutable should be your default (seriously, just use it)
  • DateTimeZone keeps you sane across continents
  • DateInterval makes math actually make sense
  • Carbon makes everything beautiful

The Rules That Actually Matter:

  1. UTC for storage. Always. No exceptions.
  2. DateTimeImmutable or Carbon's copy() saves you from debugging nightmares
  3. Validate inputs like your production uptime depends on it (because it does)
  4. Be explicit with timezones—your future self will thank you
  5. Test with frozen time. Trust me on this.
  6. DST will surprise you. Be ready.
  7. Cache those timezone objects. Your servers will thank you.

Master these patterns, and you'll build applications that handle time correctly, no matter where your users are or what edge cases they throw at you. And isn't that the whole point?

Further Reading


Building PHP applications with datetime? Contact us for consultation.