icanhazstring/symfony-time-machine
Symfony Time Machine makes time-dependent Symfony code testable by letting you freeze, advance, or reset the application clock. Use it to create deterministic tests and simulate past or future dates without changing your system time.
First, calibrate expectations: this is a Symfony bundle, not a Laravel package. There is no service provider, no config/time-machine.php, and it will not install into a Laravel app. As a Laravel developer you reach for it in two situations only: (a) you maintain a Symfony service alongside Laravel ones, or (b) you want to borrow the pattern and you already have Carbon::setTestNow() doing the job in the Laravel half.
Pin deliberately — 0.1.1 is a patch release on a dormant project, and the changelog is a single line ("Add timemachine handlers") with no migration notes:
composer require --dev icanhazstring/symfony-time-machine:0.1.1
Register it only in config/bundles.php for dev/test, never for prod:
return [
SymfonyTimeMachineBundle::class => ['dev' => true, 'test' => true],
];
Then verify what you actually got before writing any tests:
bin/console debug:container TimeMachine --show-private
bin/console debug:container --tag=time_machine.handler # tag name is a guess — confirm from the bundle's service config
bin/console list | grep -i time
Where to look first, in this order: the 0.1.0...0.1.1 diff (it is small — read the handler classes and the service definition directly), then the bundle's README.md, then the service class it registers. Confirm four things before you build on it — the namespace/class you should type-hint, whether the shift is applied to a ClockInterface-style abstraction or to a DateTime shim, what a "handler" is here and how they are collected/ordered, and whether there is a console command or route to set the time. The second and third points determine everything downstream (see Gotchas).
The new handlers are the interesting part of 0.1.1: they imply an extension point where the bundle applies its shift to more than one time source. Whether that means "pluggable coverage for third-party clocks" or just "multiple internal strategies" decides whether this package is more useful than it was last week. Read the code; the release note won't tell you.
First real use case: a functional test that asserts time-dependent business rules without sleep().
public function testTrialExpiresAfterFourteenDays(): void
{
$this->timeMachine->travelTo(new \DateTimeImmutable('2024-01-01 09:00:00 UTC'));
$this->client->request('GET', '/account');
self::assertStringContainsString('Trial: 14 days left', $this->client->getResponse()->getContent());
$this->timeMachine->travelTo(new \DateTimeImmutable('2024-01-15 09:00:01 UTC'));
$this->client->request('GET', '/account');
self::assertStringContainsString('Trial expired', $this->client->getResponse()->getContent());
}
The value is obvious: subscription renewals, invoicing cutoffs, DST bugs, "end of quarter" reports, and token expiry all become instant and deterministic.
Inject the clock, don't call new \DateTime(). This is the pattern the package exists to enable. Any service whose behaviour depends on "now" should receive the clock/time-machine service and call it:
final class InvoiceScheduler
{
public function __construct(private TimeMachine $timeMachine) {}
public function nextRun(): \DateTimeImmutable
{
return $this->timeMachine->now()->modify('first day of next month');
}
}
If you're retrofitting an existing codebase, do the refactor service-by-service and keep new \DateTime() out of the diff-review conversation; grep for new \DateTime, date(, time(), and strtotime( to size the job.
Map the handler set before you trust coverage. With 0.1.1 there is now more than one place time can be shifted from, which is either a gift or a footgun. Enumerate the handlers the bundle ships (debug command, or just read Resources/config/*.xml|yaml and the tagged classes), note which one your type-hint actually resolves to, and write down which of your time sources each covers. If the bundle exposes a handler interface you can implement, register a handler for any wrapper class of your own that reads the system clock — that is the only way this package reaches code it doesn't own.
Freeze once, travel many times, one test. Set the clock in setUp() to a fixture instant, then move it forward in explicit steps inside a single test. This is far more legible than five separate tests each re-seeding state, and it matches how the bug would actually manifest.
Centralise it in a test trait so every test starts from a known instant and — critically — resets in tearDown():
trait UsesTimeMachine
{
protected TimeMachine $timeMachine;
protected function setUp(): void
{
parent::setUp();
$this->timeMachine = self::getContainer()->get(TimeMachine::class);
$this->timeMachine->travelTo(new \DateTimeImmutable('2024-01-01 00:00:00 UTC'));
}
protected function tearDown(): void
{
$this->timeMachine->travelBack();
parent::tearDown();
}
}
Handler-driven integration for shared libraries. If 0.1.1's handlers are user-extensible, this is the upgrade's real payoff: instead of forking the bundle to cover Carbon or a vendor SDK's clock, you implement a handler that funnels that library's notion of "now" through the same shift. Write the reset path first — a handler that sets global state (Carbon::setTestNow(), a static offset, a lib's clock override) is exactly the thing that leaks between tests.
Demo/staging seeding. The most underrated use: pointing staging at a specific date so stakeholders can see year-end reporting, renewal emails, or promo banners without waiting for the calendar. Gate this behind an explicit env flag and a command, and treat it as a data-mutating operation.
Mixed Symfony/Laravel codebases. Do not run two time sources. Pick one authority per request. If a shared library is exercised by both test suites, wrap it so the Symfony side calls the bundle and the Laravel side calls Carbon::setTestNow($sameInstant), driven from the same fixture constant.
Browser-level tests. In WebTestCase, the kernel reboots between requests; anything held statically by the bundle or by a handler may or may not survive. Set the time after the first request or assert explicitly that the shift persisted, otherwise you're testing a coin flip.
The scope trap — still the deciding question, but 0.1.1 moves it. No PHP package can intercept new \DateTime() in arbitrary third-party code. It can only shift time for code that asks for the time, or via a namespaced-DateTime shim (the trick symfony/phpunit-bridge's ClockMock uses). Before 0.1.1 the answer was "one service, so legacy code stays in real time". Handlers may widen that — a handler that swaps a class alias or supplies a mock clock to a known library genuinely extends coverage — but only for the specific integration points someone wrote. Read the handlers and note precisely which code paths they touch. "Half the app is in 2024, half in 2025" is still the default failure mode until you can point at a handler that covers the code under test.
A one-line changelog plus a dormant repo is a risk signal. 0.1.1 adds handlers with no documented API, no deprecation or upgrade notes, and no tests described in the release. That's fine for vendored test tooling and unacceptable for something on the request path. Before adopting, skim the diff and confirm the handler interface is stable enough that you won't have to rewrite handlers on 0.1.2. If it isn't, port the ~100 lines into your own test infrastructure instead.
Things that will never move. Database defaults (CURRENT_TIMESTAMP, NOW()), queue workers and cron on the host, sleep()/usleep(), TTLs enforced by Redis or the OS, and JWT validation done by a library that reads the system clock directly. No handler fixes SQL-side or process-side time. Budget for these. If renewal dates are computed in SQL, the clock shift buys you nothing for that assertion.
Superseded by the framework. Symfony 6.2+ shipped symfony/clock with ClockInterface, MockClock, and ClockSensitiveTrait, which covers the same ground with a first-party API and is maintained. The handler idea has a first-party analogue too (ClockMock in symfony/phpunit-bridge). For a new project, use those. For an existing one, pin 0.1.1, vendor it, or port it — don't add a transitive dependency you'll babysit through a Symfony major upgrade.
Production safety. If the bundle exposes a console command or an HTTP route that sets the current time, an accidental prod registration is a security and correctness incident (token expiry, cache TTLs, audit timestamps, signed URL validity). Handlers make this worse, not better: a handler that swaps a clock service or sets global state in prod can affect far more code than a single mutable service. The ['dev' => true, 'test' => true] guard in config/bundles.php is not optional, and add a sanity check in your deploy smoke test that debug:container TimeMachine fails in prod.
State leaks between tests. The shift is typically held in a mutable service, sometimes statically — and now possibly in per-handler global state. Without a tearDown() reset you get order-dependent failures that only reproduce when the whole suite runs; the classic symptom is a single test that passes alone and fails in CI. Reset every handler, not just the main service. If resetting is unreliable, @runInSeparateProcess is the escape hatch, at a heavy performance cost.
Handler ordering and double-shift. Once more than one handler applies a shift, an unshifted handler plus a shifted handler in the same request can produce a double offset (time jumping two days instead of one) or silently override each other by container decoration priority. If you see time moving by the wrong amount, suspect two handlers rather than a broken travelTo().
Timezone and DST. Freeze to an explicit UTC instant (... UTC in the string) and assert the display layer separately. If you freeze to a local wall-clock time and the app converts, a DST boundary will silently shift your fixture by an hour and your "expires in exactly 1 day" assertion fails twice a year. Prefer DateTimeImmutable throughout — mutable DateTime instances passed into a clock can be modified by the caller, which makes failures unreproducible.
Debugging checklist when the shift appears not to apply: debug:container to confirm which service is bound and whether autowiring resolves your type-hint to the shifted one; list the registered handlers and check the code path under test is covered by one of them; check service decoration priority if you've wrapped it or added your own handler; confirm you fetched the service from the container rather than instantiating it; and verify the code path actually injects the clock rather than calling new \DateTime().
Extension point worth knowing. If you need the shift to also cover Carbon (common in shared code), the cleanest route on 0.1.1 is a handler (if handlers are user-registrable) or a decoration of the bundle's clock service that sets Carbon::setTestNow() as a side effect and delegates. That gives you one switch for both halves of a mixed codebase — but write the reset path first, because that handler or decoration is exactly the kind of thing that leaks across tests.
How can I help you explore Laravel packages today?