Freezing and Traveling Time in Tests

+15 Mana ✨

Introduction

Time-dependent code is notoriously hard to test. Does the password reset token expire after an hour? Does the trial flip to 'expired' at midnight UTC? Does the reminder email go out 24 hours after signup? Laravel's time-travel helpers give you deterministic control over "now" so these tests stop being flaky.

Key Concepts

  • travelTo($datetime): Fix now() at an exact moment until you call travelBack().
  • travel($amount)->days(): Advance the clock by a relative amount (days/hours/minutes).
  • freezeTime(): Lock now() to the current instant so every now() call inside the test returns the same microsecond.

Real World Context

A trial-expiry job runs when the user signed up exactly 14 days ago. Without time travel you'd either (a) write a test that sleeps for 14 days, or (b) inject a fake clock into every class. Laravel's helpers let you skip both.

Deep Dive

Fixing a specific moment

php
use Illuminate\Support\Carbon;

public function test_trial_expires_after_14_days(): void
{
    $this->travelTo(Carbon::parse('2026-04-14 12:00:00'));

    $user = User::factory()->create();
    $this->assertFalse($user->trialExpired());

    $this->travel(14)->days();

    $this->assertTrue($user->fresh()->trialExpired());
}

travelTo sets a fixed "now"; travel(14)->days() then moves it forward relative to that fixed moment. Every now(), Carbon::now(), today(), and today()->addDays(n) in your code sees the shifted time.

Freezing time for sub-second assertions

php
public function test_token_is_stored_with_current_timestamp(): void
{
    $this->freezeTime();

    $token = PasswordResetToken::generate('user@example.com');

    $this->assertEquals(now(), $token->created_at);
}

Without freezeTime(), the test would be flaky: now() when the assertion runs is a microsecond later than now() inside generate().

Using a closure for a scoped travel

php
$this->travelTo(now()->addHours(2), function () use ($user) {
    $this->assertTrue($user->sessionExpired());
});

// Back to the original 'now' automatically
$this->assertFalse($user->sessionExpired());

The closure form auto-reverts — you don't need to remember travelBack().

Common Pitfalls

  1. Forgetting travelBack() in non-closure tests — the fake time leaks into the next test, causing spooky cross-test failures. Prefer the closure form where possible, or use freezeTime() inside a setUp that has a matching tearDown.
  2. Calling time() directly — the PHP built-in ignores Laravel's time travel. Always use now() or Carbon::now().

Best Practices

  1. Freeze time at the top of any test that asserts on timestamps — eliminates entire categories of flakes.
  2. Use real calendar dates, not now()->addYears(100) — human-readable dates help when a test fails years from now.

Summary

  • travelTo(datetime) fixes now at an absolute moment.
  • travel(n)->days() advances the fixed moment.
  • freezeTime() is the micro-level version for timestamp equality.
  • The closure form of travelTo auto-reverts, so prefer it.
✓ Completed