Wie ihr die aktuelle Zeit zu einer austauschbaren Abhängigkeit macht statt sie fest im Code zu verankern
In fast jeder Anwendung gibt es Logik, die von der aktuellen Zeit abhängt: Ablaufdaten von Gutscheinen, Fristen für Bestellstornierungen, oder zeitgesteuerte Freischaltungen. Wird diese Zeit über ein direktes new \DateTime() im Code abgefragt, wird jeder Test, der einen bestimmten Zeitpunkt simulieren muss, entweder zu einer fragilen Konstruktion mit echten Sleep-Aufrufen oder schlicht unmöglich zu schreiben. Die Symfony Clock-Component löst dieses Problem, indem sie die aktuelle Zeit als injizierbare Abhängigkeit behandelt, die sich in Tests durch eine deterministische Implementierung ersetzen lässt, ohne eine einzige Millisekunde echter Wartezeit.
Inhaltsverzeichnis
- 1. Das Problem mit new DateTime() direkt im Anwendungscode
- 2. ClockInterface und NativeClock als Fundament
- 3. MockClock für deterministische, reproduzierbare Tests
- 4. Dependency Injection statt statischer Zeitaufrufe im gesamten Projekt
- 5. Die statische Clock-Facade für schwer erreichbaren Legacy-Code
- 6. Praxisbeispiel: Ein Ablaufdatum-Check vollständig durchgetestet
- 7. Zeitzonen-Handling und die Rolle von DateTimeImmutable
- 8. Die Clock-Component in Kombination mit Messenger und Scheduler
- 9. Die Clock-Component schrittweise in bestehende Projekte einführen
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit new DateTime() direkt im Anwendungscode
Ein Aufruf von new \DateTime() oder new \DateTimeImmutable() mitten in einem Service bindet dessen Verhalten fest an die tatsächliche Systemzeit des Servers, auf dem der Code gerade läuft. Das klingt zunächst harmlos, wird aber sofort zum Problem, sobald ein Test genau prüfen will, was am 31. Dezember um 23:59 Uhr passiert, oder wie sich ein Ablaufdatum verhält, das exakt in der nächsten Sekunde erreicht wird.
Der klassische Workaround besteht darin, die Zeit als zusätzlichen Parameter durch mehrere Methodenaufrufe zu reichen oder in Tests tatsächlich mit sleep() zu warten, bis ein bestimmter Zeitpunkt erreicht ist. Beide Ansätze sind unschön: Der erste verschmutzt Methodensignaturen mit einem Parameter, der eigentlich eine Infrastruktur-Abhängigkeit ist, der zweite macht Tests langsam und anfällig für knappe Zeitfenster, die je nach Systemlast unterschiedlich ausfallen können.
2. ClockInterface und NativeClock als Fundament
Die Symfony Clock-Component definiert das Interface Symfony\Component\Clock\ClockInterface mit im Kern zwei Methoden: now(): \DateTimeImmutable liefert den aktuellen Zeitpunkt, und sleep(float $seconds): void ermöglicht kontrolliertes Warten. Die produktive Standard-Implementierung NativeClock nutzt dabei tatsächlich die Systemzeit und wird automatisch als Service im Symfony-Container registriert, sobald das Paket symfony/clock installiert ist.
Durch Dependency Injection erhält jeder Service, der eine Zeitabfrage benötigt, einfach ClockInterface im Konstruktor, statt selbst ein DateTime-Objekt zu erzeugen. Damit wird die aktuelle Zeit zu einer austauschbaren Abhängigkeit wie jede andere auch, etwa eine Datenbank-Verbindung oder ein HTTP-Client, und lässt sich in Tests exakt genauso einfach durch eine alternative Implementierung ersetzen.
3. MockClock für deterministische, reproduzierbare Tests
Für Tests bietet die Component die Klasse Symfony\Component\Clock\MockClock, die im Konstruktor ein festes Startdatum entgegennimmt und dieses Datum bei jedem Aufruf von now() konsistent zurückgibt, bis es explizit über modify() oder sleep() verändert wird. Dadurch lässt sich jeder beliebige Zeitpunkt in einem Test exakt simulieren, ganz ohne echte Wartezeit oder Abhängigkeit vom tatsächlichen Ausführungszeitpunkt des Tests.
Das folgende Beispiel zeigt einen Service, der prüft, ob ein Rabattcode anhand seines Ablaufdatums noch gültig ist, vollständig über die injizierte Clock statt über eine direkte Zeitabfrage im Code.
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Clock\ClockInterface;
/**
* Prüft, ob ein Rabattcode anhand seines Ablaufdatums noch gültig ist.
*/
final class DiscountCodeValidator
{
public function __construct(
private readonly ClockInterface $clock,
) {
}
/**
* Gibt zurück, ob der Rabattcode bereits abgelaufen ist.
*
* @param \DateTimeImmutable $expiresAt Das Ablaufdatum des Rabattcodes
* @return bool True, wenn das Ablaufdatum bereits überschritten ist
*/
public function isExpired(\DateTimeImmutable $expiresAt): bool
{
return $this->clock->now() > $expiresAt;
}
}
4. Dependency Injection statt statischer Zeitaufrufe im gesamten Projekt
In einem Symfony-Projekt ist ClockInterface dank Autowiring ohne jegliche manuelle Konfiguration nutzbar, ein Service muss lediglich einen Konstruktor-Parameter dieses Typs deklarieren, um automatisch die produktive NativeClock-Instanz zu erhalten. In Tests lässt sich dieselbe Abhängigkeit über den Test-Container oder direkt beim manuellen Instanziieren des Services durch eine MockClock-Instanz ersetzen.
Als generelle Regel gilt: In Domain- und Application-Services sollte niemals direkt new \DateTime(), new \DateTimeImmutable() oder die Funktion time() aufgerufen werden, sondern ausschließlich über die injizierte Clock. Dadurch wird die 'aktuelle Zeit' explizit zu einer sichtbaren Abhängigkeit im Konstruktor, statt einer versteckten globalen Wahrheit, die sich zwischen Testläufen unvorhersehbar ändert.
5. Die statische Clock-Facade für schwer erreichbaren Legacy-Code
Für Fälle, in denen echte Dependency Injection kurzfristig nicht praktikabel ist, etwa in alten statischen Helper-Klassen oder Value-Objects, die außerhalb des Containers instanziiert werden, bietet die Component zusätzlich die statische Fassade Symfony\Component\Clock\Clock mit den Methoden Clock::now() und Clock::sleep(). In Tests lässt sich der globale Zustand über Clock::set(new MockClock('2026-01-01')) setzen und beeinflusst anschließend jeden Aufruf von Clock::now() im gesamten Testlauf.
Dieser globale Zustand ist ausdrücklich ein Kompromiss und sollte nicht als dauerhafte Lösung betrachtet werden, da globaler veränderlicher Zustand Tests wieder anfälliger für unerwartete Seiteneffekte zwischen Testfällen macht, insbesondere wenn vergessen wird, ihn nach einem Test zurückzusetzen. Echte Dependency Injection über ClockInterface bleibt daher die bevorzugte Lösung, sobald sie möglich ist.
6. Praxisbeispiel: Ein Ablaufdatum-Check vollständig durchgetestet
Ausgehend vom DiscountCodeValidator aus dem Beispiel oben lässt sich ein vollständiger Unit-Test schreiben, der eine MockClock mit einem fixen Datum instanziiert, den Validator mit dieser Clock konstruiert, und anschließend isExpired() sowohl für ein Datum vor als auch nach dem fixierten Zeitpunkt prüft, komplett ohne echte Wartezeit oder Abhängigkeit von der tatsächlichen Systemzeit.
Entscheidend ist dabei, dass die Assertions vollständig reproduzierbar sind, unabhängig davon, an welchem Tag der Test tatsächlich ausgeführt wird. Ein Test, der stattdessen das echte 'jetzt' gegen ein hartkodiertes zukünftiges Datum prüft, funktioniert zunächst zuverlässig, wird aber irgendwann in der Zukunft automatisch fehlschlagen, sobald das hartkodierte Datum in der Vergangenheit liegt, was mit einer injizierten MockClock von vornherein ausgeschlossen ist.
7. Zeitzonen-Handling und die Rolle von DateTimeImmutable
ClockInterface::now() liefert grundsätzlich ein \DateTimeImmutable-Objekt und nie das veränderliche \DateTime, was versehentliche Mutation eines Zeitobjekts verhindert, das an mehreren Stellen im Code geteilt wird. NativeClock respektiert dabei die in der PHP-Konfiguration hinterlegte Standard-Zeitzone, kann aber im Konstruktor auch explizit mit einer eigenen \DateTimeZone instanziiert werden.
Bei international verteilten Anwendungen empfiehlt es sich, die Clock projektweit konsistent auf UTC zu konfigurieren und eine Zeitzonen-Konvertierung erst in der Präsentationsschicht vorzunehmen, wenn ein Zeitpunkt tatsächlich einem Nutzer angezeigt wird. Das vermeidet subtile Vergleichsfehler zwischen Services, die sonst mit unterschiedlichen impliziten Zeitzonen arbeiten könnten.
8. Die Clock-Component in Kombination mit Messenger und Scheduler
Der Symfony Scheduler nutzt intern bereits die Clock-Component für die Berechnung seiner Trigger-Zeitpunkte, wodurch sich auch geplante, wiederkehrende Aufgaben in Tests vollständig deterministisch simulieren lassen, indem man dem Scheduler über die Container-Konfiguration eine MockClock unterschiebt, statt tatsächlich Stunden oder Tage auf den nächsten Trigger warten zu müssen.
Auch selbst geschriebene Messenger-Handler, die zeitabhängige Entscheidungen treffen, etwa 'diese Nachricht nur werktags zwischen 8 und 18 Uhr verarbeiten', profitieren erheblich davon, wenn sie konsequent ClockInterface statt eigener Zeitlogik verwenden, da sich dann auch Randfälle wie der genaue Übergang von Freitagabend auf Samstag in einem Test präzise nachstellen lassen.
9. Die Clock-Component schrittweise in bestehende Projekte einführen
Eine vollständige Migration muss nicht auf einmal geschehen: Neue Services sollten von Anfang an mit ClockInterface geschrieben werden, während bestehende Services bei der nächsten inhaltlichen Berührung, etwa einem Bugfix oder Refactoring, schrittweise umgestellt werden können, wobei jeweils der zugehörige Test gleichzeitig auf MockClock umgestellt wird.
Der Aufwand pro Service ist dabei überschaubar: ein zusätzlicher Konstruktor-Parameter und das Ersetzen eines direkten new \DateTime()-Aufrufs durch $this->clock->now(). Der Testgewinn dagegen ist erheblich, insbesondere bei Logik rund um Ablaufdaten, Fristen oder zeitgesteuerten Workflows, die vorher entweder gar nicht oder nur mit fragilen Sleep-basierten Tests abgedeckt waren.
| Klasse/Interface | Zweck | Verwendung | Beispiel |
|---|---|---|---|
| ClockInterface | Abstraktion für die aktuelle Zeit | Per Dependency Injection in Services | $this->clock->now() |
| NativeClock | Produktions-Implementierung | Automatisch im Container registriert | Liefert die echte Systemzeit |
| MockClock | Test-Implementierung mit fixer Zeit | In PHPUnit-Tests instanziieren | new MockClock('2026-01-01') |
| Clock (Facade) | Statischer Zugriff für Legacy-Code | Wenn Dependency Injection nicht möglich ist | Clock::now() |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Clock-Component für testbare Zeitlogik: Das Wichtigste auf einen Blick
ClockInterface
Abstrahiert die aktuelle Zeit als injizierbare Abhängigkeit statt direktem DateTime-Aufruf.
NativeClock
Produktive Standard-Implementierung, liefert die tatsächliche Systemzeit, automatisch registriert.
MockClock
Ermöglicht deterministische Tests mit fixem oder kontrolliert vorgespultem Zeitpunkt.
Best Practice
Niemals new DateTime() in Domain-Services, immer über die injizierte Clock arbeiten.