Testing Time-Dependent Code with Carbon::setTestNow(), travel(), and freezeTime()
Any test that touches now(), a trial expiration, or a rate-limit window has the same problem: the clock keeps moving while the test runs, and "assert this trial is expired" needs a reliable way to make that true without a sleep() call. Laravel exposes Carbon's test-time helpers directly on the base TestCase, and they're worth knowing well before you reach for manually swapping dates in factories.
travelTo(): pin the clock to an exact moment
use Illuminate\Support\Carbon;
it('marks a trial as expired after 14 days', function () {
$account = Account::factory()->create(['trial_started_at' => now()]);
$this->travelTo(now()->addDays(15));
expect($account->fresh()->onTrial())->toBeFalse();
});
travelTo() sets Carbon::setTestNow() under the hood, so every call to now(), Carbon::now(), and today() anywhere in the application — not just in the test file — returns the frozen instant until it's reset.
travel(): relative jumps
it('sends a renewal reminder 3 days before the subscription ends', function () {
$subscription = Subscription::factory()->create(['ends_at' => now()->addDays(3)]);
$this->travel(3)->days();
Artisan::call('subscriptions:send-renewal-reminders');
Mail::assertSent(RenewalReminder::class);
});
travel() reads naturally for relative jumps: $this->travel(1)->day(), $this->travel(30)->minutes(), $this->travel(-1)->week() to move backward. Chain ->days() or ->minutes() at the end to specify the unit.
freezeTime() and travelBack()
Sometimes you don't want to move to a different moment — you want to stop time entirely so that two calls to now() a few milliseconds apart in the same test return an identical value, avoiding flaky off-by-a-millisecond assertions:
it('stamps the exact same processed_at for a batch of jobs', function () {
$this->freezeTime();
ProcessOrderBatch::dispatchSync($orders);
expect($orders->fresh()->pluck('processed_at')->unique())->toHaveCount(1);
});
Always call $this->travelBack() when a test is done manipulating time, or rely on Laravel resetting it automatically between tests via the RefreshDatabase/base TestCase teardown — forgetting this is the classic cause of "this test passes alone but fails in the full suite" bugs, because a frozen clock from one test leaks into the next.
afterEach(function () {
$this->travelBack();
});
Why not just fake the value in a factory?
You could write Account::factory()->create(['trial_started_at' => now()->subDays(15)]) and skip time travel entirely — and for a single, isolated assertion, that's often simpler. Reach for travelTo()/travel() instead when the code under test calls now() in multiple places that all need to agree (a scheduled command, a chain of jobs, a model event listener), or when you're testing the passage of time itself, like a scheduler command that should only fire once per day. Faking one field gets you a stale timestamp; freezing the clock gets you a consistent world.