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" status

Integration 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.