UID, Clock & String — Trois composants utilitaires modernes
TL;DR — Trois composants Symfony "petits mais essentiels" qui remplacent du code maison fragile.
symfony/uidfournitUuid(v1, v3, v4, v5, v6, v7) etUlidavec conversion bin/string/hex, intégration Doctrine type, et performance optimale (v7 trié naturellement par temps, idéal en clé primaire).symfony/clockintroduitClockInterface(NativeClock,MockClock,MonotonicClock) pour éliminer lesnew \DateTimeImmutable()cachés dans le code ; on freeze le temps en test sans bricolage.symfony/stringoffreUnicodeString/ByteString/CodepointString(chainable, immutable, Unicode-correct) plusSlugger(AsciiSluggerstandard, ou Cocur compatible) pour générer des slugs propres. Adopter ces trois composants, c'est supprimer une majorité des bugs de fuseau horaire, d'UUID non-triables, et de manipulation Unicode foireuse.
🧠 Mental model — ASCII + analogie
┌──────────────────────────────────────────────────────────┐
│ symfony/uid │
│ Uuid::v4() ─► aléatoire (cassé pour index DB) │
│ Uuid::v7() ─► timestamp + random (trié naturel) │
│ new Ulid() ─► 26 chars, lexicographiquement OK │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ symfony/clock │
│ ClockInterface::now() ─► \DateTimeImmutable │
│ │
│ prod: NativeClock test: MockClock('2026-01-15') │
│ ├─ sleep(60) │
│ └─ modify('+1 day') │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ symfony/string │
│ u('Héllo Wörld')->lower()->snake() = 'hello_world' │
│ $slugger->slug('Ça déménage !') = 'Ca-demenage' │
│ │
│ ByteString: octets bruts │
│ CodepointString:codepoints Unicode (sans grapheme) │
│ UnicodeString: graphemes (recommandé) │
└──────────────────────────────────────────────────────────┘Analogie : ces trois composants sont les outils de plomberie d'Ikea qu'on aurait dû avoir depuis 10 ans. Avant : on bricolait. Maintenant : on a la bonne clé pour chaque vis.
- UID : "j'ai besoin d'un identifiant unique" ⇒ on cesse d'écrire
bin2hex(random_bytes(16))ou des UUID v4 catastrophiques en index. - Clock : "j'ai besoin de l'heure" ⇒ on cesse d'appeler
new \DateTime()partout (impossible à mocker). - String : "j'ai besoin de manipuler du texte" ⇒ on cesse de mélanger
strtolower/mb_strtolower/preg_replaceà la main.
🛠️ Code minimal (PHP 8.2+)
symfony/uid — Génération et conversion
composer require symfony/uid<?php
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\UuidV4;
use Symfony\Component\Uid\UuidV6;
use Symfony\Component\Uid\UuidV7;
// Génération
$v4 = Uuid::v4(); // aléatoire pur
$v6 = Uuid::v6(); // v1 réordonné, trié par temps
$v7 = Uuid::v7(); // timestamp ms + random (RFC 9562)
$ulid = new Ulid(); // 128 bits, 26 char base32
// Représentations
echo $v7->toRfc4122(); // 0190a8c5-e0f0-7000-8000-1234567890ab
echo $v7->toBase58(); // gQ6X...
echo $v7->toBase32(); // 01HXJ...
echo bin2hex($v7->toBinary()); // 16 octets bruts (BINARY(16) en DB)
// Reconstruction
$u = Uuid::fromString('0190a8c5-e0f0-7000-8000-1234567890ab');
$u = Uuid::fromBinary($v7->toBinary());
// Validation
Uuid::isValid('not-a-uuid'); // falseIntégration Doctrine (recommandée v7 pour PK)
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
class Order
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
public function __construct() {
$this->id = Uuid::v7();
}
public function getId(): Uuid { return $this->id; }
}# config/packages/doctrine.yaml
doctrine:
dbal:
types:
uuid: Symfony\Bridge\Doctrine\Types\UuidType
ulid: Symfony\Bridge\Doctrine\Types\UlidTypesymfony/clock — Injection du temps
composer require symfony/clock<?php
namespace App\Subscription;
use Psr\Clock\ClockInterface;
final readonly class SubscriptionService
{
public function __construct(private ClockInterface $clock) {}
public function isExpired(Subscription $sub): bool
{
return $sub->getExpiresAt() < $this->clock->now();
}
public function renewFor(Subscription $sub, \DateInterval $interval): void
{
$sub->setExpiresAt($this->clock->now()->add($interval));
}
}# Symfony 6.3+ : autowiring de ClockInterface vers NativeClock
# Pas de config nécessaire si symfony/clock est installé.Helpers globaux clock
use function Symfony\Component\Clock\now;
use function Symfony\Component\Clock\use_clock;
use Symfony\Component\Clock\MockClock;
// Dans un test
use_clock(new MockClock('2026-01-15 10:00:00'));
$result = now(); // 2026-01-15 10:00:00symfony/string — Manipulation Unicode-safe
composer require symfony/string<?php
use function Symfony\Component\String\u;
use function Symfony\Component\String\b;
use Symfony\Component\String\UnicodeString;
use Symfony\Component\String\Slugger\AsciiSlugger;
// API chainable, immutable
$s = u('Héllo Wörld');
echo $s->lower(); // héllo wörld
echo $s->upper(); // HÉLLO WÖRLD
echo $s->title(); // Héllo Wörld
echo $s->snake(); // héllo_wörld
echo $s->camel(); // hélloWörld
echo $s->ascii(); // Hello World (translittération)
echo $s->length(); // 11 (graphemes, correct)
echo $s->slice(0, 5); // Héllo
echo $s->replace('Wörld', 'PHP'); // Héllo PHP
echo $s->truncate(8, '…'); // Héllo W…
echo $s->startsWith('Héllo'); // true
// Slug
$slugger = new AsciiSlugger('fr');
echo $slugger->slug('Ça déménage chez Léa !'); // Ca-demenage-chez-Lea
echo $slugger->slug('Ça déménage chez Léa !', '_'); // Ca_demenage_chez_Lea
// Multi-locale
$slugger->slug('München', '-', 'de'); // Muenchen (ß→ss, ü→ue)Différence ByteString / CodepointString / UnicodeString
use function Symfony\Component\String\b;
use function Symfony\Component\String\u;
// Émoji "famille" composé de plusieurs codepoints
$family = '👨👩👧';
b($family)->length(); // 25 (octets UTF-8)
mb_strlen($family); // 4 (codepoints + ZWJ)
u($family)->length(); // 1 (1 grapheme = 1 caractère perçu)UnicodeString est presque toujours le bon choix pour de la logique métier (compter les caractères d'un tweet, tronquer un titre). ByteString pour des protocoles binaires.
🎯 Patterns courants
1. UUID v7 partout en PK
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
public function __construct() { $this->id = Uuid::v7(); }Avantages vs IDENTITY :
- Pas de round-trip DB pour obtenir l'ID (utile en bulk insert).
- Pas de fuite de cardinalité (un attaquant ne peut pas deviner le nombre d'orders existants).
- Distribuable : 2 services génèrent des IDs sans coordination.
Avantage de v7 vs v4 :
- Tri lexicographique = tri temporel ⇒ index B-tree efficace, pas de page splits aléatoires.
- Indexation 2-3× plus rapide sur des millions de lignes.
2. BINARY(16) au lieu de CHAR(36)
Avec UuidType Doctrine, le stockage par défaut est BINARY(16) (sur MySQL). Gain : -56 % de taille d'index. Lecture/écriture transparente, les API renvoient toujours la forme RFC 4122.
3. ULID pour des clés exposées publiquement
$short = (new Ulid())->toBase32(); // 01HXJBQYAZK5N7QRZB1VKEX0CR (26 chars)Avantage vs UUID : plus court, URL-safe sans tirets, lexicographiquement triable. Désavantage : pas standard RFC, moins universel que UUID.
4. Mock clock dans les tests métier
final class SubscriptionServiceTest extends TestCase
{
public function testIsExpiredAfterOneMonth(): void
{
$clock = new MockClock('2026-01-01 00:00:00');
$service = new SubscriptionService($clock);
$sub = new Subscription(new \DateTimeImmutable('2026-01-01'));
$service->renewFor($sub, new \DateInterval('P1M'));
$this->assertSame('2026-02-01', $sub->getExpiresAt()->format('Y-m-d'));
$clock->modify('+2 months');
$this->assertTrue($service->isExpired($sub));
}
}Sans ClockInterface, ce test devrait soit attendre 2 mois, soit injecter un fake DateTime ⇒ couplage et fragilité.
5. Slug stable pour URL
final readonly class ArticleSlugListener
{
public function __construct(private AsciiSlugger $slugger) {}
public function prePersist(Article $a, LifecycleEventArgs $args): void
{
if (!$a->getSlug()) {
$a->setSlug(strtolower($this->slugger->slug($a->getTitle())->toString()));
}
}
}6. Truncate respectant les graphemes
// Mauvais : strtolower + substr peut couper un caractère multibyte au milieu
$preview = substr($body, 0, 200) . '…'; // peut produire des octets invalides
// Bon
$preview = u($body)->truncate(200, '…', UnicodeString::TRUNCATE_WORDS_AFTER);7. Detection de présence (contains)
u($email)->ignoreCase()->containsAny(['+spam', '+test', '+dev']); // bool8. Performance vs str_*
Pour des opérations simples sur ASCII pur (URL, identifiants techniques), les fonctions str_* restent plus rapides (~5-10× plus rapides sur des micro-bench). Pour du texte utilisateur multilingue, UnicodeString est le bon choix : la correctness Unicode justifie le coût.
Benchmark indicatif (PHP 8.3, 1 million d'opérations) :
strtolower('Hello') : 0.04 s
mb_strtolower('Hello') : 0.08 s
u('Hello')->lower()->toString() : 0.45 s
substr('Hello World', 0, 5) : 0.05 s
mb_substr('Hello World', 0, 5) : 0.10 s
u('Hello World')->slice(0, 5) : 0.40 sL'allocation des objets UnicodeString est le principal coût. Pour des opérations en boucle sur des millions d'éléments, préférer une combinaison ciblée mb_* + Transliterator. Pour le contrôleur web standard, le surcoût est invisible.
9. Inflector pour pluraliser/singulariser
use Symfony\Component\String\Inflector\EnglishInflector;
use Symfony\Component\String\Inflector\FrenchInflector;
$en = new EnglishInflector();
$en->pluralize('child'); // ['children']
$en->pluralize('foot'); // ['feet']
$en->singularize('mice'); // ['mouse']
$fr = new FrenchInflector();
$fr->pluralize('cheval'); // ['chevaux']
$fr->pluralize('travail'); // ['travails', 'travaux'] // ambigu, retourne les 2Utile pour générer des noms de routes (/users ↔ User), des classes (UserController ↔ users), ou des messages i18n basés sur des entités.
10. Composer un slug "stable + lisible"
final readonly class StableSlugger
{
public function __construct(private AsciiSlugger $slugger) {}
public function generate(string $title, ?int $id = null): string
{
$base = u($this->slugger->slug($title, '-', 'fr')->toString())
->lower()
->truncate(60, '', UnicodeString::TRUNCATE_WORDS_AFTER);
return $id !== null
? sprintf('%s-%d', $base, $id)
: (string) $base;
}
}Ce pattern combine AsciiSlugger (transliteration + sanitization) avec UnicodeString::truncate (limite caractères perçus, pas octets) et l'ID en suffixe pour garantir l'unicité même si deux articles partagent le même titre.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
symfony/uid
| Version | Évolutions |
|---|---|
| 5.3 | Introduction. Support v1, v3, v4, v5, v6, NIL. |
| 6.2 | Support UUID v7 (draft RFC, stabilisé). |
| 6.4 LTS | API stable. Uuid::isValid(), MaxUuid::class. |
| 7.0 | Drop des helpers dépréciés. |
| 7.1 | Optimisation perf de la génération v7 (-30 % temps CPU). |
symfony/clock
| Version | Évolutions |
|---|---|
| 6.2 | Introduction. ClockInterface, NativeClock, MockClock. Compatible PSR-20. |
| 6.3 | Helper now(), intégration avec Scheduler. |
| 6.4 LTS | Stable. MonotonicClock pour benchmarks. |
| 7.0 | Pas de breaking change. |
| 7.1 | Support timezone par défaut configurable globalement. |
symfony/string
| Version | Évolutions |
|---|---|
| 5.0 | Introduction. UnicodeString, ByteString, CodepointString. |
| 5.4 LTS | AsciiSlugger stable, locales supportées. |
| 6.0 | API gelée. |
| 6.4 LTS | Inflector (singulariser/pluraliser) intégré dans EnglishInflector / FrenchInflector. |
| 7.0 | Drop de ChainCaseConverter interne déprécié. |
| 7.1 | Améliorations Unicode (Unicode 15 support). |
🧭 Comment un staff engineer raisonne
UID — l'arbre de décision réel
La question n'est pas « UUID ou auto-increment » mais « quelles propriétés cet identifiant doit-il garantir ». On déroule trois axes : génération (client/serveur/DB ?), ordre (l'index doit-il rester compact ?), exposition (l'ID fuit-il vers l'extérieur ?).
Besoin → choix
─────────────────────────────────────────────────────────────
PK indexée, volume élevé, génération distribuée → UUID v7
PK mais l'ordre temporel est un risque de fuite → UUID v4 + index dédié, ou v7 + ID public séparé
ID public court, URL-safe, triable → ULID (base32)
Token secret (CSRF, reset, API key avant hash) → random_bytes(32), PAS un UUID
ID déterministe à partir d'un namespace+nom → UUID v5 (SHA-1) — idempotence d'import
Compat système legacy attendant un v1 → v6 (même info, ordonné) plutôt que v1Le piège classique du v7 en prod : v7 encode un timestamp milliseconde en clair dans les 48 premiers bits. Si vos IDs sont visibles publiquement (/order/0190a8c5-...), vous révélez l'heure exacte de création de chaque ressource — exploitable pour de l'analyse de trafic concurrent (« combien de commandes entre 14h et 15h »). Le pattern staff : v7 en PK interne, identifiant public dérivé séparé (ULID re-randomisé, ou un hashid sur un compteur). Ne jamais confondre « identifiant de stockage » et « identifiant d'API ».
Clock — pourquoi c'est une question d'architecture, pas de test
Injecter ClockInterface n'est pas « pour les tests ». C'est rendre explicite une dépendance qui était cachée. new \DateTimeImmutable() est un appel à l'état global du système (l'horloge murale du serveur) au même titre que lire un fichier ou faire un appel réseau — sauf qu'il est invisible. Un service qui appelle now() en dur est non-déterministe et donc non-raisonnable : son comportement dépend de quand il tourne. Le staff engineer traite l'horloge comme une I/O, point.
Corollaire production : MonotonicClock (basé sur hrtime()) sert à mesurer des durées ; NativeClock à horodater des faits métier. Mélanger les deux est un bug subtil — un timeout calculé avec l'horloge murale saute si NTP corrige l'heure ou au changement DST. Pour « est-ce que 30 secondes se sont écoulées », c'est MonotonicClock ; pour « cet abonnement expire le 1er du mois », c'est NativeClock.
Tradeoffs UID en un tableau
| Type | Bits temporels | Ordonné | Fuite | Standard | Usage canonique |
|---|---|---|---|---|---|
| v4 | aucun | non | aucune | RFC 9562 | tokens, secrets, PK sans contrainte d'ordre |
| v1 | 60 bits + MAC | oui | MAC + horloge | RFC 9562 | éviter (legacy) |
| v6 | 60 bits | oui | horloge µs | RFC 9562 | remplacement v1 ordonné |
| v7 | 48 bits ms | oui | horloge ms | RFC 9562 | PK moderne par défaut |
| v5 | aucun | non | aucune | RFC 9562 | ID déterministe (namespace) |
| ULID | 48 bits ms | oui | horloge ms | spec ULID | ID public court base32 |
📈 Observabilité & production
UID — corréler les logs
Un UUID v7 est aussi un timestamp. En incident, on peut extraire l'instant de création directement d'un ID, sans requête DB :
$created = $uuid->getDateTime(); // \DateTimeImmutable — dispo sur v1/v6/v7/UlidPratique pour répondre « cette commande a-t-elle été créée avant ou après le déploiement de 14h32 ? » à partir d'un seul ID dans une stack trace. Ne fonctionne pas sur v4 (pas de composante temporelle) — d'où l'intérêt de v7 même côté observabilité.
Clock — propager le temps de test jusqu'aux fixtures et au scheduler
use_clock() agit sur le helper global now() et sur Symfony\Component\Clock\Clock. Le Scheduler de Symfony et les RateLimiter consomment cette même horloge : en test fonctionnel, freezer le clock global rend déterministes les déclenchements cron et les fenêtres de rate limit. En revanche, Doctrine created_at via #[ORM\HasLifecycleCallbacks] qui appelle new \DateTime() n'est PAS mocké — d'où la règle : faire passer même les timestamps d'entité par le clock injecté (paramètre de constructeur, comme dans l'exemple Transaction).
String — coût caché en hot path
UnicodeString alloue un objet par opération et chaque maillon de chaîne crée une nouvelle instance (immutabilité). Sur un endpoint à fort QPS qui slugifie en boucle, cela se voit au profiler (allocation GC). Mesure staff : profiler avant d'optimiser, mais si un slug est recalculé à chaque requête, le mettre en cache (colonne slug persistée, calculée une fois au prePersist) plutôt que d'optimiser la lib. La bonne optimisation n'est pas mb_* vs UnicodeString, c'est ne pas recalculer.
⚠️ Pitfalls — 6-10
Utiliser UUID v4 comme PK MySQL/PostgreSQL ⇒ catastrophe de performance sur les inserts massifs (pages B-tree fragmentées). Toujours préférer v7 ou v6 si tri temporel.
Stockage UUID en
VARCHAR(36)⇒ +56 % d'espace, +30 % de latence d'index. UtiliserBINARY(16)viaUuidTypeDoctrine.Uuid::v4()->toRfc4122()vs(string) $uuid⇒ les deux marchent, mais__toStringpeut être déroutant dans des contextes avec strict_types. Préférer explicitement->toRfc4122().new \DateTimeImmutable()dans un service ⇒ impossible à mocker. InjecterClockInterfacepartout, sans exception.MockClockqui ne progresse pas :$clock->now()retourne la même valeur à chaque appel. Si le code teste un timeout, il faut appeler$clock->sleep(5)ou$clock->modify('+5 seconds').Confondre
mb_strlenetUnicodeString::length()⇒ pour les emoji composés (drapeaux, familles),mb_strlenretourne le nombre de codepoints, pas le nombre de caractères perçus. Si vous limitez un tweet à 280 caractères, utilisezu($text)->length().AsciiSluggersans locale ⇒ pour de l'allemand, vous voulezü → ue, pasü → u. Toujours passer la locale au constructeur ou àslug().Slug non normalisé en lowercase ⇒
slug('Hello World')produitHello-World. Pour des URLs canoniques, appliquer->lower()après.Conversion
Ulid → Uuid: ULID et UUID v7 sont tous deux 128-bits trié par temps, mais leur layout binaire diffère. Ne pas mélanger les types dans une colonne.AsciiSluggersur des chinois/japonais ⇒ produit une chaîne vide ou très réduite. Pour des langues non-latines, prévoir un fallback (ex. l'ID numérique de l'entité) ou utiliser un translittérateur ICU spécifique.UUID v1/v6 et fuite d'information : v1 contient l'horodatage de génération et un « node » qui, dans l'implémentation classique, dérive de l'adresse MAC (Symfony randomise le node par défaut, mais des v1 produits ailleurs peuvent fuiter la MAC). v6 garde l'horodatage µs mais réordonne pour l'index. Ces métadonnées sont parfois exploitables (corrélation d'identifiants, fingerprinting d'infrastructure, datation de ressources). v4 (aucune info) ou v7 (timestamp ms seulement) sont plus discrets — et pour un ID public, voir la section staff : préférer un identifiant non-corrélé au temps.
MonotonicClockconfondu avecNativeClock:MonotonicClockretourne un\DateTimeImmutablebasé surhrtime()(monotone, non lié à l'heure murale). À utiliser pour des mesures de durée (benchmark), pas pour des horodatages métier. Si vous l'utilisez en PK ou en log timestamp, vous obtenez des dates relatives au démarrage du process.
🧪 Testing
Tester un service utilisant Clock
use Symfony\Component\Clock\MockClock;
public function testRenewalAddsThirtyDays(): void
{
$clock = new MockClock('2026-01-15 12:00:00', 'UTC');
$service = new SubscriptionService($clock);
$sub = new Subscription();
$service->renew($sub);
$this->assertSame(
'2026-02-14 12:00:00',
$sub->getRenewedUntil()->format('Y-m-d H:i:s')
);
}Freezer le temps globalement dans un test
use function Symfony\Component\Clock\use_clock;
protected function setUp(): void
{
parent::setUp();
use_clock(new MockClock('2026-01-15'));
}
protected function tearDown(): void
{
use_clock(new \Symfony\Component\Clock\NativeClock());
parent::tearDown();
}Tester une génération d'UUID v7 (ordre temporel)
public function testUuidV7IsOrderedByTime(): void
{
$a = Uuid::v7();
usleep(2000);
$b = Uuid::v7();
$this->assertLessThan($b->toBase32(), $a->toBase32());
// Tri lexicographique = tri temporel
}Tester un slugger
public function testSlugStripsAccentsAndPunctuation(): void
{
$slugger = new AsciiSlugger('fr');
$this->assertSame('Ca-demenage-chez-Lea', (string) $slugger->slug('Ça déménage chez Léa !'));
}Tester UnicodeString length
public function testGraphemeCount(): void
{
$this->assertSame(1, u('👨👩👧')->length());
$this->assertSame(11, u('Héllo Wörld')->length());
}🎬 Cas d'usage concrets
UUIDv7 comme PK dans banque
Une banque digitale gère des millions de transactions par mois avec des dizaines de tables Doctrine interconnectées : comptes, transferts, paiements carte, prélèvements, opérations FX, virements internationaux. Historiquement, l'app utilisait des IDENTITY MySQL (auto-increment) — pratique mais avec deux limitations connues : impossibilité de générer un ID avant l'INSERT (gênant pour les architectures CQRS event-sourced), et risque de fuite de cardinalité (un attaquant qui voit /transfer/45821 sait qu'il y a au moins 45821 transferts en base, info commerciale sensible). La migration vers UUIDv7 a été décidée à l'occasion du passage à Symfony 7. Stratégie : nouvelle colonne id typée UuidType avec BINARY(16) en stockage (gain 56% sur l'espace vs CHAR(36)), génération en PHP avec Uuid::v7() (timestamps ms triés naturellement). Avantage immédiat : un domain event peut être publié dès la construction de l'entité, avec l'ID définitif, sans attendre le flush. Les indexes B-tree restent compacts car v7 est lexicographiquement trié par temps (contrairement à v4 qui produit des page splits aléatoires). Sur des benchmarks internes, l'insert dans une table de 500M lignes a gagné 35% en throughput. L'API publique expose désormais les IDs en base58 (Uuid::v7()->toBase58()) plus compacts (22 caractères) et URL-safe, sans révéler le timestamp à l'œil nu. Les anciennes URLs avec ID entier restent supportées par un alias pour assurer la rétrocompatibilité (table de mapping legacy_id → uuid).
MockClock dans tests cabinet d'avocats
Un cabinet d'avocats utilise une application interne de gestion d'échéances juridiques avec calculs complexes : délai d'appel de 30 jours à partir de la signification, prescription quinquennale qui peut être interrompue par actes interruptifs, péremption d'instance après 2 ans d'inactivité, etc. Ces calculs sont au cœur du métier : une erreur de date peut coûter une affaire au client et engager la responsabilité civile professionnelle. Tester ces règles avec des new \DateTimeImmutable() codés en dur dans le code applicatif était un cauchemar : les tests passaient en mars mais échouaient en avril à cause du changement d'heure d'été, certaines règles "30 jours" ne se comportaient pas comme attendu aux frontières de mois (février, mois à 31 jours). La refonte a injecté ClockInterface partout, et les tests utilisent massivement MockClock. Chaque scénario fixe une date précise (new MockClock('2026-04-15 09:30:00 Europe/Paris')), exécute le calcul, vérifie le résultat. Pour les scénarios temporels (vérifier qu'au J+31 l'appel est forclos), le clock est avancé avec $clock->modify('+31 days') et la vérification est rejouée. Bonus inattendu : un bug latent a été découvert sur la péremption d'instance — le code calculait 730 jours au lieu de 2 années calendaires, ce qui faisait une différence quand l'année bissextile était impliquée. Sans MockClock, ce bug aurait pu mettre des années avant d'être détecté en production sur le bon cas particulier. Les juniors apprécient aussi de pouvoir simuler le déroulement d'un dossier sur 2 ans en quelques millisecondes pour valider toutes les notifications attendues.
AsciiSlugger pour URL produits e-commerce
Une marketplace e-commerce internationale (FR/DE/IT/ES/EN) vend 200 000 références produit issues de centaines de marques. Chaque produit a une URL SEO-friendly du type /produit/{slug}-{sku}. Le slug doit être stable (changer un slug = casser un lien externe = perdre du SEO), unique, court (max 60 caractères pour les contraintes Google), et correctement transliteré selon la langue. Les pièges sont nombreux : un titre allemand "Müller Schraube für Möbel ß-spezial" doit donner mueller-schraube-fuer-moebel-ss-spezial (transliteration ü→ue, ß→ss), un titre italien "Caffè espresso 200g" doit donner caffe-espresso-200g. La solution utilise AsciiSlugger avec passage explicite de la locale produit, encapsulé dans un service StableSlugger qui ajoute une logique métier : truncation respectueuse des graphemes, lowercase systématique, suffixage avec le SKU pour garantir l'unicité, conservation du slug existant si le titre est juste modifié à la marge (Levenshtein < 20%). Côté indexation Elasticsearch, le slug est utilisé comme keyword pour des recherches exactes, et le titre original conserve son champ text analysé par langue. Une commande de maintenance vérifie chaque nuit qu'aucun slug n'a été dupliqué (contrainte unique en DB de toute façon, mais la commande alerte aussi sur les "near-duplicates" qui pourraient confondre les utilisateurs). Pour les langues non-latines (chinois, arabe — à venir), un fallback est prévu : le slug devient simplement le SKU si la transliteration produit une chaîne vide. Le score Lighthouse SEO sur les pages produit dépasse 96 partout, et le taux d'indexation Google a augmenté de 12% en 3 mois grâce à des URLs plus propres.
🛠️ Exemple end-to-end
Service complet de gestion d'événements bancaires utilisant UUIDv7 comme PK, ClockInterface pour la testabilité, et UnicodeString/AsciiSlugger pour les libellés.
<?php
// src/Entity/Transaction.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'transactions')]
#[ORM\Index(fields: ['accountId', 'createdAt'])]
class Transaction
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(type: UuidType::NAME)]
private Uuid $accountId;
#[ORM\Column(length: 60)]
private string $reference;
#[ORM\Column(length: 200)]
private string $label;
#[ORM\Column]
private int $amount; // cents
#[ORM\Column(length: 3)]
private string $currency;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(
Uuid $accountId,
string $label,
int $amount,
string $currency,
\DateTimeImmutable $createdAt,
) {
$this->id = Uuid::v7();
$this->accountId = $accountId;
$this->label = $label;
$this->amount = $amount;
$this->currency = $currency;
$this->createdAt = $createdAt;
$this->reference = $this->id->toBase58();
}
public function getId(): Uuid { return $this->id; }
public function getReference(): string { return $this->reference; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}<?php
// src/Banking/TransactionRecorder.php
declare(strict_types=1);
namespace App\Banking;
use App\Entity\Transaction;
use App\Repository\TransactionRepository;
use Psr\Clock\ClockInterface;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\Uid\Uuid;
use function Symfony\Component\String\u;
final readonly class TransactionRecorder
{
public function __construct(
private TransactionRepository $repo,
private ClockInterface $clock,
private SluggerInterface $slugger,
) {}
public function record(
Uuid $accountId,
string $rawLabel,
int $amount,
string $currency,
): Transaction {
$label = u($rawLabel)
->trim()
->truncate(200, '…')
->toString();
$transaction = new Transaction(
accountId: $accountId,
label: $label,
amount: $amount,
currency: $currency,
createdAt: $this->clock->now(),
);
$this->repo->persist($transaction);
return $transaction;
}
public function generateUserFacingSlug(Transaction $tx): string
{
$base = u((string) $this->slugger->slug($tx->getLabel(), '-', 'fr'))
->lower()
->truncate(50, '', \Symfony\Component\String\UnicodeString::TRUNCATE_WORDS_AFTER)
->toString();
return sprintf('%s-%s', $base, $tx->getReference());
}
}<?php
// tests/Banking/TransactionRecorderTest.php
declare(strict_types=1);
namespace App\Tests\Banking;
use App\Banking\TransactionRecorder;
use App\Repository\TransactionRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Uid\Uuid;
final class TransactionRecorderTest extends TestCase
{
public function testRecordUsesInjectedClock(): void
{
$clock = new MockClock('2026-05-23 14:30:00');
$repo = $this->createMock(TransactionRepository::class);
$repo->expects(self::once())->method('persist');
$recorder = new TransactionRecorder($repo, $clock, new AsciiSlugger('fr'));
$accountId = Uuid::v7();
$tx = $recorder->record($accountId, 'Achat boulangerie Lefèvre', -550, 'EUR');
self::assertSame(
'2026-05-23 14:30:00',
$tx->getCreatedAt()->format('Y-m-d H:i:s'),
);
}
public function testLabelIsTruncatedRespectingGraphemes(): void
{
$clock = new MockClock('2026-05-23');
$repo = $this->createMock(TransactionRepository::class);
$recorder = new TransactionRecorder($repo, $clock, new AsciiSlugger('fr'));
$longLabel = str_repeat('Café espresso ☕ ', 50);
$tx = $recorder->record(Uuid::v7(), $longLabel, 250, 'EUR');
// assertion sur longueur grapheme
$this->assertLessThanOrEqual(200, mb_strlen($tx->getLabel()));
}
public function testSlugIsLowercaseAndStableWithReference(): void
{
$clock = new MockClock('2026-05-23');
$repo = $this->createMock(TransactionRepository::class);
$recorder = new TransactionRecorder($repo, $clock, new AsciiSlugger('fr'));
$tx = $recorder->record(Uuid::v7(), 'Café Lefèvre — Paris 11e', -550, 'EUR');
$slug = $recorder->generateUserFacingSlug($tx);
self::assertMatchesRegularExpression('/^cafe-lefevre[-a-z0-9]*-[1-9a-z]+$/i', $slug);
self::assertStringEndsWith($tx->getReference(), $slug);
}
public function testTransactionsAreOrderedByTime(): void
{
$clock = new MockClock('2026-05-23 10:00:00');
$repo = $this->createMock(TransactionRepository::class);
$recorder = new TransactionRecorder($repo, $clock, new AsciiSlugger('fr'));
$accountId = Uuid::v7();
$tx1 = $recorder->record($accountId, 'Tx 1', 100, 'EUR');
$clock->modify('+1 second');
$tx2 = $recorder->record($accountId, 'Tx 2', 200, 'EUR');
// UUIDv7 trié lexicographiquement = trié temporellement
self::assertLessThan(
(string) $tx2->getId(),
(string) $tx1->getId(),
);
}
}# config/services.yaml
services:
Symfony\Component\String\Slugger\AsciiSlugger:
arguments: ['fr']
App\Banking\TransactionRecorder:
autowire: trueL'exemple démontre la synergie des trois composants : UUIDv7 pour des IDs distribuables et triés par temps (PK + référence externe Base58), ClockInterface injectée pour des tests déterministes même sur des logiques temporelles, et UnicodeString + AsciiSlugger pour des labels et slugs robustes face aux caractères accentués des noms de commerce français.
🔁 Quand utiliser / éviter
UID :
- Utiliser v7 pour toutes les nouvelles PK distribuables.
- Utiliser v4 uniquement pour des secrets/tokens non indexés (CSRF token, API key avant hash).
- Éviter v1 (révèle l'adresse MAC) et v3/v5 (cas d'usage très spécifique : déterminisme à partir d'un namespace).
- Éviter UUID si vous avez besoin d'identifiants courts visibles à l'utilisateur ⇒ Ulid base32 (26 chars) ou un encodage type
hashids.
Clock :
- Utiliser ClockInterface partout dès qu'un service consulte l'heure courante.
- Éviter
new \DateTimeImmutable()direct dans un service métier (réservé aux contrôleurs/handlers de bord). - Éviter
MockClocksi vous testez du code qui appelle directementtime():MockClockne mocke pas la fonction PHP globale ; il faut soit refactorer pour passer par l'interface, soituopz/runkit(déconseillé).
String :
- Utiliser
UnicodeStringpour la majorité des manipulations textuelles métier. - Utiliser
ByteStringpour des protocoles binaires (bytes exacts, pas de Unicode). - Utiliser les
str_*natifs pour de l'ASCII connu et des opérations très simples (perf-critical path). - Utiliser
AsciiSluggerpour des URL friendly. Pour des slugs SEO multilingues complexes, envisagercocur/slugifyqui a plus de règles culturelles.
Comparaison synthétique
| Composant | Remplace | Bénéfice clé |
|---|---|---|
Uuid::v7() | IDENTITY SQL, UUID v4 | Distribuable, trié par temps, index DB rapide |
Ulid | UUID + base16 manuel | URL-safe, 26 chars, lexicographique |
ClockInterface | new \DateTimeImmutable() | Testable, freezable, PSR-20 |
MockClock | bricolage temps en test | Précis, contrôlable, sans dépendance |
UnicodeString | mb_* + preg_replace | Chainable, grapheme-correct, immutable |
AsciiSlugger | str_replace + transliteration maison | I18n-aware, configurable, simple |
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice se code en isolation (un test PHPUnit + le composant concerné suffisent).
1. (Échauffement) Encodeur d'ID public réversible
Objectif : exposer un UUID v7 de PK sous une forme courte URL-safe et le re-décoder côté contrôleur.
Écrire PublicId::encode(Uuid $id): string et PublicId::decode(string $s): Uuid en passant par toBase58() / fromBase58(). Ajouter un test round-trip : decode(encode($id)) == $id pour 1000 IDs générés.
Indice/Solution : Uuid::fromBase58($s) reconstruit l'objet ; comparer avec ->equals() (pas ==, qui compare les objets) ou via ->toBinary(). Piège : fromBase58 lève sur une entrée invalide — wrapper dans un try/catch et renvoyer 404, pas 500.
2. Migration v4 → v7 sans downtime
Objectif : faire cohabiter d'anciens UUID v4 et de nouveaux v7 dans la même colonne PK, en gardant un index performant pour les nouvelles lignes.
Écrire une migration Doctrine qui ajoute une colonne id_v7 BINARY(16), la peuple pour les lignes existantes en dérivant un v7 à partir de leur created_at (Uuid::fromString ne suffit pas — il faut construire un v7 à un timestamp donné), et bascule la PK. Tester que l'ordre lexicographique des nouveaux IDs reflète l'ordre temporel.
Indice/Solution : pour forger un v7 à une date précise, utiliser new UuidV7() n'expose pas le timestamp publiquement ; la voie supportée est de générer en séquence sous MockClock + use_clock() (le générateur de Symfony lit l'horloge globale depuis 6.4) ou d'accepter que le backfill ne soit que « best effort » et de ne garantir l'ordre que pour les lignes créées après bascule. Discuter ce compromis dans le test — c'est le cœur de l'exercice.
3. (Production-grade) SluggerService avec dédup et stabilité
Objectif : générer des slugs uniques, stables (un re-edit mineur ne change pas le slug), multilingues, avec fallback non-latin.
Implémenter generate(string $title, string $locale, callable $exists): string qui : transliter via AsciiSlugger($locale), lowercase, tronque à 60 graphemes (TRUNCATE_WORDS_AFTER), et si $exists($slug) renvoie true, suffixe -2, -3… Si la translittération produit une chaîne vide (chinois/arabe), fallback sur un ULID base32. Couvrir : "München"/de → muenchen, "北京"/zh → un ULID, collision sur "Hello" → hello-2.
Indice/Solution : u($slug)->isEmpty() détecte le cas non-latin. Pour la stabilité, ne pas régénérer si l'entité a déjà un slug et que la distance Levenshtein titre↔titre-précédent est faible — sinon chaque sauvegarde casse les liens externes. Le suffixe numérique doit interroger $exists en boucle bornée (max ~50 itérations puis fallback ULID pour éviter une boucle infinie sous forte collision).
4. (Casser puis réparer) Le test qui passe en mai et casse en mars
Objectif : diagnostiquer et corriger un service temporel non-déterministe.
On vous donne un TrialService::daysLeft(): int qui fait (new \DateTimeImmutable('2026-06-01'))->diff(new \DateTimeImmutable())->days. Écrire un test qui prouve la non-déterminisme (il échoue selon le jour d'exécution), puis refactorer le service pour injecter ClockInterface, et réécrire le test avec MockClock pour figer deux dates de part et d'autre de l'échéance.
Indice/Solution : le bug est le new \DateTimeImmutable() caché. Après injection, tester J-1 (MockClock('2026-05-31') → 1) et J+1 (MockClock('2026-06-02') → 0 ou négatif selon la spec choisie). Bonus : ajouter un cas au passage à l'heure d'été (Europe/Paris, 2026-03-29) pour vérifier que diff()->days ne saute pas — c'est un piège classique où ->days diffère de ->d sur les frontières DST.
5. (Avancé) Compteur de caractères « Twitter-correct »
Objectif : implémenter une limite de 280 caractères perçus qui gère emoji composés, drapeaux et accents combinés.
Écrire TweetValidator::isValid(string $text): bool et remaining(string $text): int. Tester que 👨👩👧👦 compte pour 1, qu'un é décomposé (e + accent combinant U+0301) compte pour 1, et qu'une chaîne de 281 emoji est rejetée.
Indice/Solution : u($text)->length() compte les graphemes — c'est exactement la sémantique « caractère perçu ». Vérifier le piège de la normalisation : é précomposé (U+00E9) et é décomposé (e + U+0301) doivent compter pareil ; u() gère le grapheme cluster, mais pour comparer/dédupliquer du texte normaliser d'abord en NFC. mb_strlen donnerait 2 sur le décomposé — d'où l'exercice.
6. (Architecte) Idempotence d'import via UUID v5
Objectif : importer un flux CSV de fournisseurs de façon idempotente — relancer l'import ne doit jamais créer de doublon, sans table de mapping.
Dériver la PK de chaque ligne via Uuid::v5($namespace, $businessKey) où $businessKey est une clé naturelle stable (ex. SIRET). Démontrer qu'importer deux fois le même fichier produit des INSERT ... ON DUPLICATE no-op. Discuter : que se passe-t-il si la clé naturelle change (renommage SIRET) ?
Indice/Solution : Uuid::v5(Uuid::fromString($nsUuid), $siret) est déterministe — même entrée, même UUID. L'idempotence vient de la PK, pas d'une logique applicative. Limite à expliciter : v5 est déterministe sur l'entrée, donc si le businessKey mute, l'ID mute et vous créez un doublon — d'où l'importance de choisir une clé réellement immuable (SIRET oui, nom commercial non).
🎤 En entretien
Q : Pourquoi UUID v7 plutôt que v4 en clé primaire, concrètement ? v4 est aléatoire : chaque insert tombe dans une page B-tree quelconque, provoquant des page splits et un index fragmenté → write amplification et cache miss sur des tables volumineuses. v7 préfixe un timestamp ms, donc les inserts sont quasi-séquentiels (append en fin d'index) tout en gardant l'unicité distribuée. On garde les avantages d'UUID (génération côté app, pas de fuite de cardinalité) sans le coût d'indexation de v4.
Q : Injecter ClockInterface, c'est juste pour mocker en test ? Non — c'est rendre explicite une dépendance cachée. new \DateTimeImmutable() lit l'état global du système (l'horloge murale) de façon invisible, ce qui rend le service non-déterministe et non-raisonnable. L'injection traite le temps comme une I/O ; la testabilité est un effet de bord, pas l'objectif. Bonus : ça permet aussi de simuler une timezone, de figer le temps pour le Scheduler/RateLimiter, et d'éviter les bugs DST.
Q : Quel est le risque sécurité d'exposer un UUID v7 dans une URL publique ? v7 contient le timestamp de création en clair (48 bits ms). Un tiers peut donc dater chaque ressource et faire de l'analyse de volume/trafic. Le pattern correct : v7 en PK interne, identifiant public séparé non-corrélé au temps (ULID re-randomisé, hashid sur compteur, ou simplement un v4 dédié). Ne jamais confondre identifiant de stockage et identifiant d'API.
Q : mb_strlen('👨👩👧') renvoie 4, votre validateur de tweet doit-il s'en contenter ? Non. mb_strlen compte les codepoints Unicode, pas les caractères perçus (graphemes). Un emoji « famille » est un cluster de plusieurs codepoints reliés par ZWJ : 4 codepoints mais 1 caractère à l'écran. Pour une limite « 280 caractères » au sens utilisateur, il faut u($text)->length() qui segmente en grapheme clusters. Penser aussi à normaliser (NFC) avant de comparer des chaînes accentuées décomposées.
🔗 Liens
- Doc UID : https://symfony.com/doc/current/components/uid.html
- Doc Clock : https://symfony.com/doc/current/components/clock.html
- Doc String : https://symfony.com/doc/current/components/string.html
- PSR-20 (Clock) : https://www.php-fig.org/psr/psr-20/
- RFC 9562 (UUID v6/v7/v8) : https://datatracker.ietf.org/doc/html/rfc9562
- ULID spec : https://github.com/ulid/spec
- Buildkite — "Goodbye integers, hello UUIDs" (motivation v7)
Récap final
Ces trois composants sont des investissements à très haut ROI pour une codebase Symfony moderne. Adopter Uuid::v7() comme PK élimine une classe entière de bugs de scaling et de fuite d'information. Injecter ClockInterface rend testable une dimension du code qu'on accepte trop souvent comme fragile. Utiliser UnicodeString règle d'un coup les bugs de troncature, longueur, et transliteration. La règle d'or : commencer chaque nouveau projet avec ces trois composants installés et utilisés par défaut, et migrer le legacy de manière opportuniste à chaque refactor de classe. Le coût d'adoption est faible (chacun a une API minimale et bien documentée) ; le gain en correctness et en testabilité est durable.