Immutable DateTime in 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.