Immutable DateTime in Laravel

Development Laravel

While developing my latest Laravel side project, I ran into an issue that I’ve seen before. When using a CarbonImmutable class as a value source for a Model, the time zone is silently removed and re-added, changing the time. Now, it seems to me that a regular mutable Carbon object also has an issue.

In my use case, I want to take a date/time in local time from the user, and add the user’s local timezone to calculate an absolute timestamp to store in the database. It will be sent to the UI as a JavaScript timestamp, so end users will see the date time in their local timezone.

Let’s start a simple new laravel installation with no starter kit:

cd ~/scratch
laravel new immutable
cd immutable
php artisan migrate
php artisan tinker

Instead of adding extra models, I’ll just use the User model because the created_at and updated_at properties are already cast to a datetime object. Let’s create a new User and modify the create_at property inside the tinker shell:

> use Carbon\Carbon
> $user = User::factory()->create()
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
= App\Models\User {#5285
    name: "Marvin Yost",
    email: "nmurazik@example.net",
    email_verified_at: "2025-06-25 19:16:42",
    #password: "$2y$12$pcNdSipUh6Dd5.CevSJAN.OFkqYMbB8p9Ip2tRF1foMgKrq0aIdgm",
    #remember_token: "M5aro4eKwd",
    updated_at: "2025-06-25 19:16:43",
    created_at: "2025-06-25 19:16:43",
    id: 1,
  }

> $user->created_at
= Illuminate\Support\Carbon @1750879003 {#5242
    date: 2025-06-25 19:16:43.0 UTC (+00:00),
  }

Let’s imagine that the user needs to modify the time of some event, and we’ll use the created_at property as a stand-in for whatever property on whatever other model needs a modification. The user has passed a Y-m-d H:i:s formatted local time, and a timezone that we can parse into a Carbon object.

> $createdAt = Carbon::parse('2025-01-01 08:00:00', new DateTimeZone('America/New_York'))
= Carbon\Carbon @1735736400 {#5231
    date: 2025-01-01 08:00:00.0 America/New_York (-05:00),
  }

> $createdAt->toCookieString()
= "Wednesday, 01-Jan-2025 08:00:00 EST"
> $user->created_at = $createdAt
= Carbon\Carbon @1735736400 {#5231
    date: 2025-01-01 08:00:00.0 America/New_York (-05:00),
  }

> $user->save()
= true

> $user->created_at
= Illuminate\Support\Carbon @1735718400 {#6337
    date: 2025-01-01 08:00:00.0 UTC (+00:00),
  }

> $user->created_at->toCookieString()
= "Wednesday, 01-Jan-2025 08:00:00 UTC"

So why did the date change when we gave the model a time object?

First, a workaround: use the timestamp.

> $user->created_at = $createdAt->getTimestamp()
= 1735736400

> $user->save();
= true

> $user->created_at
= Illuminate\Support\Carbon @1735736400 {#6419
    date: 2025-01-01 13:00:00.0 UTC (+00:00),
  }

> $user->created_at->toCookieString()
= "Wednesday, 01-Jan-2025 13:00:00 UTC"

There is a problem with this. If you’re using laravel-ide-helper and larastan, Stan may complain that the created_at doesn’t take an integer. That can be solved by changing the docblock, but that cascades down the code, by Stan complains about using the created_at as a DateTime when it might be an integer.

Let’s try a different technique.

> $createdAt = Carbon::parse('2025-01-01 09:00:00', new DateTimeZone('America/New_York'))
= Carbon\Carbon @1735740000 {#5241
    date: 2025-01-01 09:00:00.0 America/New_York (-05:00),
  }

> $user->created_at = $createdAt->setTimeZone(config('app.timezone'))
= Carbon\Carbon @1735740000 {#5241
    date: 2025-01-01 14:00:00.0 UTC (+00:00),
  }

Let’s make a test that looks like it should pass, but fails because of time zones:

class dateTest extends \Tests\TestCase
{
    #[\PHPUnit\Framework\Attributes\Test]
    public function test_user_date(): void
    {
        $user = \App\Models\User::factory()->create();

        $createdAt = \Carbon\CarbonImmutable::parse('2025-01-01 08:00:00', 'America/New_York');
        $user->created_at = $createdAt;
        $user->save();
        $this->assertEquals($createdAt->getTimestamp(), $user->created_at->getTimestamp());
    }
}
Failed asserting that 1735718400 matches expected 1735736400.
Expected :1735736400
Actual   :1735718400

Stepping through the entire test, delving into the Laravel code, we come to the Illuminate\Database\Eloquent\Concerns\HasAttributes class. In the setAttribute($key, $value) method, the Carbon object is cast via the $value = $this->fromDateTime($value); line.

/**
 * Convert a DateTime to a storable string.
 *
 * @param  mixed  $value
 * @return string|null
 */
public function fromDateTime($value)
{
    return empty($value) ? $value : $this->asDateTime($value)->format(
        $this->getDateFormat()
    );
}

Passing the Carbon object through fromDateTime() returns "2025-01-01 08:00:00", which is the local time without any time zone data. That is then transferred to UTC time zone without any other adjustment, and is stored as "Wednesday, 01-Jan-2025 08:00:00 GMT+0000", or 1735718400.

Assigning a Carbon object to a 'datetime' cast, the timezone needs to be in config('app.timezone) time, which by default is set to UTC. If we put this back in the tinker shell, we can use it like this:

> use Carbon\CarbonImmutable
> $createdAt = CarbonImmutable::parse('2025-01-01 08:00:00', new DateTimeZone('America/New_York'))
= Carbon\CarbonImmutable @1735736400 {#5235
    date: 2025-01-01 08:00:00.0 America/New_York (-05:00),
  }

> $user = User::factory()->create();
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
= App\Models\User {#5319
    name: "Khalil Reichel",
    email: "gretchen79@example.org",
    email_verified_at: "2025-06-26 03:29:51",
    #password: "$2y$12$aQsQz0T5xPsF3GqpoEo.hOEu6q4K2.lCneUXZ4UzMCe.owjBslSbC",
    #remember_token: "PI3TFktVQZ",
    updated_at: "2025-06-26 03:29:52",
    created_at: "2025-06-26 03:29:52",
    id: 2,
  }

> $user->created_at = $createdAt->setTimezone(config('app.timezone'))
= Carbon\CarbonImmutable @1735736400 {#5273
    date: 2025-01-01 13:00:00.0 UTC (+00:00),
  }

> $user->save()
= true

> $user->created_at->setTimeZone('America/New_York')->toCookieString()
= "Wednesday, 01-Jan-2025 08:00:00 EST"

Remember that no matter what time zone your Carbon object is, make sure it has the app default timezone before it is cast to a model attribute.

Weather in Charlotte, NC