Synchronizing Time Across Multiple Symfony Applications in Tests
The Multi-Application Testing Challenge
If you've ever tried to test time-sensitive features across multiple Symfony apps, you know the pain. A user sets a reminder at 2 PM—your frontend creates it, a worker picks it up, and the API sends the notification. But when you're running tests, how do all three apps agree on "what time is it"?
Same story with session expiration, rate limiting, scheduled publications... any feature involving time becomes a nightmare to test reliably when you have multiple services.
The trick we've been using: propagate the mocked time via HTTP headers.
The Architecture
┌─────────────────────────────────────────────────────┐
│ Behat/Cypress/Playwright Test │
│ Sets: X-Clock-Time: 1704110400.123456 │
│ X-Clock-Timezone: Europe/Paris │
└──────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Frontend Symfony App (www.example.com) │
│ ClockListener reads headers → Clock::set(MockClock) │
│ Persists to session for subsequent requests │
└──────────────────┬──────────────────────────────────┘
│ Makes API call with same headers
▼
┌─────────────────────────────────────────────────────┐
│ API Symfony App (api.example.com) │
│ ClockListener reads headers → Clock::set(MockClock) │
│ Processes request with mocked time │
└─────────────────────────────────────────────────────┘The ClockListener: Shared Across All Apps
Deploy this identical listener in every Symfony application in your stack:
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\Clock\Clock;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\DependencyInjection\Attribute\When;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
#[When(env: 'test')]
final readonly class ClockListener
{
public const string HEADER_CLOCK_TIME = 'X-Clock-Time';
public const string HEADER_CLOCK_TIMEZONE = 'X-Clock-Timezone';
private const string TIMESTAMP_FORMAT = 'U.u';
#[AsEventListener(priority: 40)]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
// Read clock time and timezone from request headers or session
$request = $event->getRequest();
$clockTime = self::extractFromRequest($request, self::HEADER_CLOCK_TIME);
$clockTimezone = self::extractFromRequest($request, self::HEADER_CLOCK_TIMEZONE);
if ($clockTime === null || $clockTimezone === null) {
return;
}
$timezone = new \DateTimeZone($clockTimezone);
$time = self::parseTimestamp($clockTime, $timezone);
Clock::set(new MockClock($time, $timezone));
}
#[AsEventListener(priority: 6)]
public function afterLoginOnKernelRequest(RequestEvent $event): void
{
// Re-apply clock after authentication to ensure it persists
$this->onKernelRequest($event);
}
#[AsEventListener]
public function onKernelFinish(FinishRequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$clock = Clock::get();
// If the Clock is not mocked, there is no purpose to persist the time and timezone
if (!$clock instanceof MockClock) {
return;
}
// Persist clock state to session for subsequent requests
$request = $event->getRequest();
if ($request->hasSession()) {
$now = $clock->now();
$request->getSession()->set(self::HEADER_CLOCK_TIME, self::formatTime($now));
$request->getSession()->set(self::HEADER_CLOCK_TIMEZONE, self::formatTimezone($now));
}
}
public static function formatTime(\DateTimeInterface $dateTime): string
{
return $dateTime->format(self::TIMESTAMP_FORMAT);
}
public static function formatTimezone(\DateTimeInterface $dateTime): string
{
return $dateTime->getTimezone()->getName();
}
private static function extractFromRequest(Request $request, string $key): ?string
{
if ($request->headers->has($key)) {
return $request->headers->get($key);
}
if ($request->hasSession() && $request->getSession()->isStarted() && $request->getSession()->has($key)) {
return $request->getSession()->get($key);
}
return null;
}
private static function parseTimestamp(string $timestamp, \DateTimeZone $timezone): \DateTimeImmutable
{
$dateTime = \DateTimeImmutable::createFromFormat(self::TIMESTAMP_FORMAT, $timestamp, $timezone);
if ($dateTime === false) {
throw new \RuntimeException(
sprintf('Cannot parse timestamp "%s" with timezone "%s"', $timestamp, $timezone->getName())
);
}
return $dateTime;
}
}A few things worth noting here: the #[When(env: 'test')] attribute keeps this out of production entirely. We check headers first, then fall back to the session—this handles redirects and multi-step forms where you don't control every request. The U.u format gives us microsecond precision, which matters more than you'd think when debugging race conditions.
Integration with Behat
Create Gherkin steps to control time in your WebContext:
use Symfony\Component\Clock\Clock;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Clock\NativeClock;
use Behat\Mink\Driver\BrowserKitDriver;
use DMore\ChromeDriver\ChromeDriver;
use App\EventListener\ClockListener;
#[BeforeScenario]
public function resetClock(): void
{
Clock::set(new NativeClock());
$driver = $this->getSession()->getDriver();
if ($driver instanceof BrowserKitDriver) {
$driver->getClient()->setServerParameters([]);
}
if ($driver instanceof ChromeDriver) {
$driver->unsetRequestHeader(ClockListener::HEADER_CLOCK_TIME);
$driver->unsetRequestHeader(ClockListener::HEADER_CLOCK_TIMEZONE);
}
}
#[Given('we are on :dateString')]
public function setCurrentDate(string $dateString): void
{
$dateTime = new \DateTimeImmutable($dateString);
$this->setRequestHeader(ClockListener::HEADER_CLOCK_TIME, ClockListener::formatTime($dateTime));
$this->setRequestHeader(ClockListener::HEADER_CLOCK_TIMEZONE, ClockListener::formatTimezone($dateTime));
Clock::set(new MockClock($dateTime));
}
#[When('I advance time by :diff')]
public function advanceTimeBy(string $diff): void
{
$this->setCurrentDate(Clock::get()->now()->modify($diff)->format('Y-m-d H:i:s'));
}
private function setRequestHeader(string $name, string $value): void
{
$driver = $this->getSession()->getDriver();
if ($driver instanceof BrowserKitDriver) {
$driver->getClient()->setServerParameter('HTTP_' . str_replace('-', '_', strtoupper($name)), $value);
} else {
$this->getSession()->setRequestHeader($name, $value);
}
}Feature example:
Feature: Article scheduling
Scenario: Scheduled article publication
Given we are on "2024-01-01 09:00:00"
And I am logged in as an editor
When I create an article "Breaking News" scheduled for "2024-01-01 14:00:00"
Then the article should be in "draft" status
When I advance time by "+5 hours"
And I refresh the page
Then the article should be in "published" statusIntegration with Cypress
Create custom commands to inject headers on all requests:
// cypress/support/commands.js
/**
* Set the mocked clock time for all subsequent requests
* @param {string} dateString - Date string like '2024-01-01 09:00:00'
*/
Cypress.Commands.add('setClock', (dateString) => {
const date = new Date(dateString);
const timestamp = (date.getTime() / 1000).toFixed(6); // Convert to U.u format
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
cy.intercept('**', (req) => {
req.headers['X-Clock-Time'] = timestamp;
req.headers['X-Clock-Timezone'] = timezone;
}).as('clockInterceptor');
});
/**
* Reset the clock to use real time
*/
Cypress.Commands.add('resetClock', () => {
// Remove all intercepts by re-defining without the clock headers
cy.intercept('**', (req) => {
req.continue();
});
});Test example:
describe('Article scheduling', () => {
beforeEach(() => {
cy.resetClock();
});
it('publishes article at scheduled time', () => {
cy.setClock('2024-01-01 09:00:00');
cy.visit('/articles/new');
cy.get('[name="title"]').type('Breaking News');
cy.get('[name="publish_at"]').type('2024-01-01 14:00');
cy.get('button[type="submit"]').click();
cy.contains('Status: Draft').should('be.visible');
// Advance time
cy.setClock('2024-01-01 14:00:01');
cy.reload();
cy.contains('Status: Published').should('be.visible');
});
it('expires session after 30 minutes', () => {
cy.setClock('2024-01-01 10:00:00');
cy.login('user@example.com', 'password');
cy.visit('/dashboard');
cy.contains('Welcome').should('be.visible');
// Advance 31 minutes
cy.setClock('2024-01-01 10:31:00');
cy.reload();
cy.url().should('include', '/login');
cy.contains('Session expired').should('be.visible');
});
});Integration with Playwright
Create helper functions to set clock via request interception:
// tests/helpers/clock.js
/**
* Set the mocked clock time for all subsequent requests
* @param {import('@playwright/test').Page} page
* @param {string} dateString - Date string like '2024-01-01 09:00:00'
*/
export async function setTestClock(page, dateString) {
const date = new Date(dateString);
const timestamp = (date.getTime() / 1000).toFixed(6); // Convert to U.u format
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Store handler reference for later cleanup
page._clockRouteHandler = async (route) => {
const headers = {
...route.request().headers(),
'X-Clock-Time': timestamp,
'X-Clock-Timezone': timezone,
};
await route.continue({ headers });
};
await page.route('**/*', page._clockRouteHandler);
}
/**
* Reset the clock to use real time
* @param {import('@playwright/test').Page} page
*/
export async function resetTestClock(page) {
if (page._clockRouteHandler) {
await page.unroute('**/*', page._clockRouteHandler);
page._clockRouteHandler = null;
}
}Test example:
import { test, expect } from '@playwright/test';
import { setTestClock, resetTestClock } from './helpers/clock';
test.describe('Article scheduling', () => {
test.afterEach(async ({ page }) => {
await resetTestClock(page);
});
test('publishes article at scheduled time', async ({ page }) => {
await setTestClock(page, '2024-01-01 09:00:00');
await page.goto('/articles/new');
await page.fill('[name="title"]', 'Breaking News');
await page.fill('[name="publish_at"]', '2024-01-01 14:00');
await page.click('button[type="submit"]');
await expect(page.locator('text=Status: Draft')).toBeVisible();
// Advance time
await setTestClock(page, '2024-01-01 14:00:01');
await page.reload();
await expect(page.locator('text=Status: Published')).toBeVisible();
});
test('expires session after 30 minutes', async ({ page }) => {
await setTestClock(page, '2024-01-01 10:00:00');
await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await expect(page.locator('text=Welcome')).toBeVisible();
// Advance 31 minutes
await setTestClock(page, '2024-01-01 10:31:00');
await page.reload();
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('text=Session expired')).toBeVisible();
});
});Use Clock in Your Application Code
Replace direct DateTime instantiation with Clock::get() (or by injecting an ClockInterface instance):
use Symfony\Component\Clock\Clock;
class ArticleService
{
public function publishArticle(Article $article): void
{
// ❌ Before - uses real time always
$article->setPublishedAt(new \DateTimeImmutable());
// ✅ After - respects mocked time in tests
$article->setPublishedAt(Clock::get()->now());
}
public function isScheduledForPublication(Article $article): bool
{
if (!$article->getScheduledAt()) {
return false;
}
// ✅ Compare against mocked "now"
return $article->getScheduledAt() <= Clock::get()->now();
}
}In Doctrine entities:
use Symfony\Component\Clock\Clock;
class Comment
{
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
// ✅ Use Clock instead of new \DateTimeImmutable()
$this->createdAt = Clock::get()->now();
}
}Where We Use This
Pretty much anywhere time matters: scheduled notifications, session timeouts, JWT expiration, betting cooldowns and daily limits, subscription trials and renewals, rate limiting windows, audit logs... basically any feature where "what time is it" affects behavior.
The pattern really shines for cron-like scheduled tasks—you can test "this job should run at midnight" without waiting for midnight or mocking half your codebase.
Going Further
HTTP headers work great for web requests, but what about the rest of your stack?
For Symfony Messenger, you can create a custom stamp that carries the clock state (timestamp + timezone) and a middleware that attaches it on dispatch, then restores the mocked clock when the worker picks up the message. Same idea, different transport.
For Console commands, an event listener on ConsoleEvents::COMMAND can read environment variables or a config file to set up the mock clock before your command runs—useful for testing scheduled tasks.
The pattern extends to pretty much anything: Redis, external APIs, frontend SPAs. The core idea stays the same—capture the mocked time at the boundary, pass it along, restore it on the other side.