Switch Laravel to Immutable Dates with CarbonImmutable

Carbon mutates your dates in place. Switch Laravel to CarbonImmutable with Date::use() and the immutable_datetime cast, and learn what breaks on the way.

Steven Richardson
Steven Richardson
· 7 min read

A date range on a billing screen was returning a start date a month later than it should. The bug had survived code review twice and a test suite that only ever asserted the end date. The cause was one line — $end = $start->addMonth() — and the fix was moving the whole Laravel app onto CarbonImmutable.

Carbon mutates because PHP's DateTime mutates#

Carbon\Carbon extends PHP's DateTime, and DateTime modifiers rewrite the object you called them on. Carbon inherits that behaviour and adds a fluent API on top, which makes it very easy to write date maths that looks functional but is not.

$start = $subscription->current_period_start; // Carbon, mutable
$end   = $start->addMonth();                  // mutates $start, returns the same object

$start->toDateString(); // 2026-09-15  ← should still be 2026-08-15
$end->toDateString();   // 2026-09-15
$start === $end;        // true

The variable name promises one thing and the object does another. Worse, the damage travels: pass that Carbon instance into a helper and the helper can rewrite your local variable from three stack frames away.

use Carbon\CarbonInterface;

final class BillingPeriod
{
    public function __construct(private readonly CarbonInterface $anchor) {}

    public function endsAt(): CarbonInterface
    {
        return $this->anchor->addMonth(); // rewrites $anchor every call
    }
}

$period = new BillingPeriod(now());

$period->endsAt(); // one month out
$period->endsAt(); // two months out
$period->endsAt(); // three months out

readonly does not save you here. The property reference never changes; the object behind it does. That distinction is the whole reason immutable value objects built with PHP readonly classes only work when the values inside them are immutable too.

CarbonImmutable: the same API, without the mutation#

PHP has shipped the answer since 5.5. DateTimeImmutable implements the same DateTimeInterface as DateTime, exposes the same methods, and returns a new instance from every modifier instead of rewriting itself.

$mutable = new DateTime('2026-08-15');
$mutable->modify('+7 days');
echo $mutable->format('Y-m-d');   // 2026-08-22 — the original changed

$immutable = new DateTimeImmutable('2026-08-15');
$immutable->modify('+7 days');    // return value discarded
echo $immutable->format('Y-m-d'); // 2026-08-15 — untouched

$later = $immutable->modify('+7 days');
echo $later->format('Y-m-d');     // 2026-08-22

Carbon\CarbonImmutable extends DateTimeImmutable and carries the identical Carbon API. Swapping the class is the entire change:

use Carbon\CarbonImmutable;

$start = CarbonImmutable::parse('2026-08-15')->startOfDay();
$end   = $start->addMonth();

$start->toDateString(); // 2026-08-15
$end->toDateString();   // 2026-09-15
$start === $end;        // false

Both classes implement Carbon\CarbonInterface, so anything type-hinting the interface accepts either one. Neither class extends the other.

Make CarbonImmutable the Laravel default with Date::use()#

Doing this per-call is not a strategy. Laravel resolves dates through the Date facade, and Date::use() swaps the class it builds. Register it in AppServiceProvider — the same boot() method where I put Model::shouldBeStrict() when catching N+1 queries early with Eloquent strict mode.

namespace App\Providers;

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Date::use(CarbonImmutable::class);
    }
}

That one line reaches further than most people expect:

now();                     // Carbon\CarbonImmutable
today();                   // Carbon\CarbonImmutable
Date::parse('2026-08-15'); // Carbon\CarbonImmutable

$post->created_at;         // Carbon\CarbonImmutable
$post->updated_at;         // Carbon\CarbonImmutable

Carbon::now();             // still Carbon\Carbon — the facade is bypassed

Model timestamps come along for free because Eloquent's asDateTime() builds every date through the Date facade. That also means a plain datetime cast returns a CarbonImmutable once Date::use() is set — no cast changes required.

Cast Eloquent dates with immutable_datetime#

So why does Laravel date casting ship immutable_datetime and immutable_date at all? Because they are a guarantee rather than a default. immutable_datetime calls ->toImmutable() on the result explicitly, so the attribute is immutable whether or not anyone remembered to call Date::use() — and it survives a future developer removing that line from the provider.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Subscription extends Model
{
    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'trial_ends_at' => 'immutable_datetime',
            'cancelled_at'  => 'immutable_datetime',
            'renews_on'     => 'immutable_date',
        ];
    }
}

I use both: Date::use() for the app-wide default, and explicit immutable casts on the models where date maths actually happens. Date-driven queries read the same either way, which is why patterns like auto-deleting stale records with the Prunable trait need no changes at all — now()->subMonths(3) is a value, not a mutation.

What breaks when a Laravel app moves to immutable dates#

Nothing throws. That is the problem. Any call that relied on the side effect becomes a no-op that quietly returns a discarded instance.

// ❌ Worked under Carbon. Does nothing under CarbonImmutable.
$deadline = $invoice->issued_at;
$deadline->addDays(30);

// ✅ Keep the return value
$deadline = $invoice->issued_at->addDays(30);

Grep for modifier calls used as bare statements — ->add, ->sub, ->startOf, ->endOf, ->setTime with no assignment on the left. Those are your migration list.

The second category is type hints. CarbonImmutable does not extend Carbon, and Laravel ships no Illuminate\Support\CarbonImmutable, so any signature declaring Carbon\Carbon or Illuminate\Support\Carbon will now be handed something it refuses. Widen those to CarbonInterface or DateTimeInterface:

- public function overdueSince(Carbon $date): bool
+ public function overdueSince(CarbonInterface $date): bool

Static analysis finds both categories far faster than reading diffs. On a large codebase I run PHPStan, generate a baseline, and work through it in slices — the same approach as adopting PHPStan on a legacy Laravel app with a baseline.

Your tests barely change#

This is the part people brace for and then find is a non-event. Illuminate\Support\Carbon::setTestNow() sets the mock clock on Carbon\Carbon and Carbon\CarbonImmutable, and travelTo() and freezeTime() both go through it.

it('ends the trial fourteen days after signup', function () {
    $this->travelTo('2026-08-15 09:00:00');

    $subscription = Subscription::factory()->create();

    expect($subscription->trial_ends_at)
        ->toBeInstanceOf(CarbonImmutable::class)
        ->and($subscription->trial_ends_at->toDateString())->toBe('2026-08-29');
});

Serialisation is unchanged too. serializeDate() still emits the same UTC ISO-8601 strings, so API payloads are byte-identical — worth confirming with snapshot tests over your Laravel API responses if you want proof rather than faith.

Gotchas and Edge Cases#

The silent no-op is the whole risk. PHP will not warn you that you threw away the only copy of a computed value. PHP 8.5's #[\NoDiscard] attribute exists precisely for this class of mistake, and if Carbon ever adopts it the migration gets a lot louder — see stop silently ignoring return values that matter.

Carbon::now() bypasses the switch entirely. Date::use() only affects dates built through the Date facade and the helpers that wrap it. Direct static calls on Carbon\Carbon keep returning mutable instances. Prefer now(), today() and Date::parse() in application code.

$model->created_at->addDay() was already a no-op. Eloquent's datetime cast is not cached the way Attribute accessors are — every read of $model->created_at builds a fresh instance. Mutating it never persisted anything, even before the switch. If that pattern exists in your codebase, it has been broken all along.

Escape hatches both ways. ->toImmutable() and ->toMutable() convert between the two, and isMutable() / isImmutable() tell you which you have. Useful when a third-party package hands you a mutable Carbon and you want to store it safely.

boot() is usually early enough, but not always. If another service provider creates dates inside its own register() method, move Date::use() into AppServiceProvider::register() so it runs first.

Packages that type-hint Carbon. Some third-party Laravel packages still declare Carbon\Carbon in signatures. Convert at the boundary with ->toMutable() rather than reverting the whole app.

Wrapping Up#

Add Date::use(CarbonImmutable::class) to AppServiceProvider, set immutable_datetime casts on the models that do date maths, then grep for discarded modifier calls and fix them in one pass. The change is small; the review it forces is the valuable part.

If this lands well, the same reasoning applies to the rest of your domain layer — PHP 8.5's clone with makes immutable objects cheap to update, and readonly classes as value objects give you somewhere sensible to put those dates.

FAQ#

What is the difference between Carbon and CarbonImmutable?

Carbon extends PHP's DateTime and its modifiers change the instance you call them on, returning that same object. CarbonImmutable extends DateTimeImmutable and returns a brand new instance from every modifier, leaving the original untouched. The method names and arguments are identical, so swapping between them is a class change rather than an API change.

How do I make Laravel use CarbonImmutable by default?

Call Date::use(CarbonImmutable::class) in the boot() method of App\Providers\AppServiceProvider. After that, now(), today(), Date::parse() and Eloquent's created_at and updated_at attributes all return CarbonImmutable instances. Direct calls to Carbon\Carbon static methods are not affected, so prefer the helpers in application code.

What is the immutable_datetime cast in Laravel?

It is an Eloquent attribute cast that returns the column as a CarbonImmutable instance instead of a mutable Carbon one. Add it in your model's casts() method as 'trial_ends_at' => 'immutable_datetime'. There is a matching immutable_date cast for date-only columns, which normalises the time to the start of the day.

Why does addDay change my original date in PHP?

Because Carbon inherits from DateTime, and DateTime methods mutate the object rather than returning a copy. $date->addDay() modifies $date and returns the same instance, so any other variable pointing at that object sees the new value too. Using CarbonImmutable or DateTimeImmutable removes the side effect.

Should I use DateTimeImmutable instead of DateTime?

For new code, yes. Immutable dates remove an entire category of action-at-a-distance bug at no runtime cost worth measuring, and the API is the same. The one thing to watch is that discarded return values become silent no-ops instead of working by side effect, so audit existing code before flipping an established codebase over.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.