src/Domain→ 100% pure PHP, no Symfony, no Doctrine, no attributessrc/Application→ Use Cases, DTOs, Interfaces (TransactionManager, Clock, UserProvider)src/Infrastructure→ Symfony Controllers, Doctrine Repos, Redis adapters, Messenger handlers
Symfony Messenger with doctrine transport is the official outbox implementation:
- Domain events are dispatched to
outboxbus (doctrine transport) - Written atomically to
messenger_messagestable in the same transaction - Dedicated worker:
bin/console messenger:consume outbox --limit=1000 --time-limit=55 - Worker publishes to RabbitMQ (or other brokers)
- Deduplication: consumer side with Redis key
outbox:processed:{message_id}(UUID v7), TTL 8 days
- Header:
Idempotency-Key→ UUID v4, lowercase, no hyphens (e.g.a0eebc999c0b4ef8bb6d6bb9bd380a11) - Redis key:
idempotency:{key}→ Hash (status + response) - TTL: 48 hours
- States:
- Missing → acquire lock
idempotency:lock:{key}NX EX 30s → process - processing → 409 IDEMPOTENCY_KEY_IN_USE
- completed → return cached response
- Missing → acquire lock
- Pessimistic Write Locking (
SELECT ... FOR UPDATE SKIP LOCKEDwhere possible) - All write transactions in
SERIALIZABLEisolation - Lock timeout: exactly 8 seconds (
SET LOCAL lock_timeout = '8s') - Automatic retry with exponential backoff + full jitter (max 6 attempts)
$transactionManager->transactional(fn() => $useCase->execute(...), maxAttempts: 6);Delay formula: min(1000ms, random_between(0.5, 1.5) × (50ms × 2^attempt))
The TransactionManager MUST only retry on transient database lock errors.
- Max attempts: 6
- Per-attempt behavior:
- Attempt 1 runs with
SET LOCAL lock_timeout = '8s' - If the transaction fails with a lock-timeout or deadlock error, the manager applies the backoff delay and retries
- Validation errors, unique constraint violations, and business rule violations MUST NOT be retried
- Attempt 1 runs with
Error mapping:
- If all 6 attempts fail due to lock timeout or deadlock:
- The use case MUST throw a domain-level exception that maps to HTTP 503 with error code
LOCK_TIMEOUT
- The use case MUST throw a domain-level exception that maps to HTTP 503 with error code
- On the first non-retryable error:
- The manager MUST stop retrying and bubble up the exception
Example timeline (lock contention):
- T0: Attempt 1 → lock timeout at ~8s → backoff delay
- T1: Attempt 2 → quick success or another lock timeout
- Tn: After 6 failed attempts → final
LOCK_TIMEOUTerror returned to API layer
| Key Pattern | Type | TTL | Purpose |
|---|---|---|---|
idempotency:{key} |
HASH | 48 hours | Idempotency response cache |
idempotency:lock:{key} |
String | 30 seconds | Distributed lock |
rate_limit:user:{user_id}:{minute} |
Int | sliding | Per-user limiter (10/min) |
rate_limit:global:{minute} |
Int | sliding | Global limiter (1000/min) |
outbox:processed:{message_id} |
SET | 8 days | Deduplication of relayed messages |
Symfony RateLimiter with Redis adapter:
- Per user (
X-Mock-User-Id): 10 requests/minute - Global (IP): 1000 requests/minute
@Scheduled(cron="*/30 * * * *")
- Batch size: 200
- Query uses
FOR UPDATE SKIP LOCKED - Publishes
ReservationExpiredto outbox bus