The project, and why I built it
Part 1 — Absorbing the load: asynchronous ingestion with Symfony Messenger
Php
final class EventController
{
public function __construct(
private readonly MessageBusInterface $messageBus,
private readonly RateLimiterFactory $eventIngestionLimiter,
private readonly LoggerInterface $logger,
) {
}
#[Route('/api/v1/events', name: 'events_ingest', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$limiter = $this->eventIngestionLimiter->create($request->getClientIp() ?? 'unknown');
if (!$limiter->consume(1)->isAccepted()) {
return new JsonResponse(['error' => 'Too many requests.'], 429);
}
$payload = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$clientId = (string) ($payload['client_id'] ?? '');
$endpoint = (string) ($payload['endpoint'] ?? '');
$idempotencyKey = $request->headers->has('Idempotency-Key')
? $request->headers->get('Idempotency-Key')
: IdempotencyKey::fromPayload($clientId, $endpoint, $request->getContent())->value();
$this->messageBus->dispatch(new LogEventMessage($clientId, $endpoint, (string) $idempotencyKey));
return new JsonResponse(status: 202); // Accepted : traité de façon asynchrone
}
}
Part 2 — Idempotency: the number one enemy of usage-based billing
Php
final readonly class IdempotencyKey
{
private string $value;
public function __construct(string $value)
{
$trimmed = trim($value);
if ($trimmed === '' || strlen($trimmed) > 128) {
throw new \InvalidArgumentException('Idempotency key must be between 1 and 128 characters.');
}
$this->value = $trimmed;
}
public static function fromPayload(string $clientId, string $endpoint, string $rawBody): self
{
return new self(hash('sha256', $clientId . '|' . $endpoint . '|' . $rawBody));
}
public function value(): string
{
return $this->value;
}
}
Php
final readonly class LogClientEventUseCase
{
public function __construct(
private EventRepositoryInterface $eventRepository,
private IdempotencyGuardInterface $idempotencyGuard,
private ?LoggerInterface $logger = null,
) {
}
public function execute(LogEventCommand $command): void
{
$idempotencyKey = new IdempotencyKey($command->idempotencyKey);
if ($this->idempotencyGuard->hasBeenProcessed($idempotencyKey)) {
throw new DuplicateEventException(
sprintf('Event with idempotency key "%s" was already processed.', $idempotencyKey->value())
);
}
$event = new Event(/* ... */);
$this->eventRepository->save($event);
$this->idempotencyGuard->markAsProcessed($idempotencyKey);
}
}
Part 3 — Security: applying OWASP concretely, not in theory
Php
final class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly string $expectedApiKeyHash,
) {
}
public function authenticate(Request $request): Passport
{
$providedKey = (string) $request->headers->get('X-Api-Key', '');
if ($providedKey === '' || !hash_equals($this->expectedApiKeyHash, hash('sha256', $providedKey))) {
throw new AuthenticationException('Invalid or missing API key.');
}
return new SelfValidatingPassport(
new UserBadge('api-client', fn () => new InMemoryUser('api-client', null, ['ROLE_API_CLIENT']))
);
}
}
Yaml
framework:
rate_limiter:
event_ingestion:
policy: 'sliding_window'
limit: 200
interval: '60 seconds'
Part 4 — Testing: TDD, and where BDD stops here
Php
final class BillingCalculatorTest extends TestCase
{
private BillingCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new BillingCalculator();
}
public function test_it_returns_zero_price_below_free_tier_threshold(): void
{
$invoice = $this->buildInvoiceWithEvents(500);
$price = $this->calculator->calculate($invoice);
self::assertSame(0, $price->amountInCents());
}
public function test_it_bills_only_events_exceeding_free_tier(): void
{
$invoice = $this->buildInvoiceWithEvents(1200);
$price = $this->calculator->calculate($invoice);
self::assertSame(200 * 2, $price->amountInCents()); // 200 événements facturables x 2 centimes
}
}
Php
// (extrait résumé) LogClientEventUseCaseTest
public function test_it_rejects_a_duplicate_event_without_persisting_it_twice(): void
{
$guard = $this->createMock(IdempotencyGuardInterface::class);
$guard->method('hasBeenProcessed')->willReturn(true);
$repository = $this->createMock(EventRepositoryInterface::class);
$repository->expects(self::never())->method('save');
$this->expectException(DuplicateEventException::class);
// ... exécution du use case avec une clé déjà traitée
}
The billing calculation, to close the loop
Php
final class BillingCalculator
{
private const PRICE_PER_EVENT_CENTS = 2; // 0,02 € par événement ingéré
private const FREE_TIER_THRESHOLD = 1000;
public function calculate(Invoice $invoice): Price
{
$billableEvents = max(0, $invoice->totalEvents() - self::FREE_TIER_THRESHOLD);
return Price::fromCents($billableEvents * self::PRICE_PER_EVENT_CENTS);
}
}
What I'd do differently (honestly)
- Limited test coverage. Only two test files exist (BillingCalculatorTest, LogClientEventUseCaseTest). The controller, the Doctrine repositories, and the Messenger handler have no integration tests at this stage — that's the logical next step for the project, not a finished aspect of it.
- No tooled BDD. As explained above, adding Behat with a handful of Gherkin scenarios covering the "ingestion → invoice" journey would be the natural next step to document behavior from a business perspective, beyond the unit tests.
- Deliberately simplified authentication. The API key is compared against a static hash in configuration; SECURITY.md itself documents that a real production rollout would require storage in a Secret Manager (a dedicated service — e.g. AWS Secrets Manager or HashiCorp Vault — for storing and automatically rotating credentials outside the application's own configuration) with rotation, rather than a single static value.
- This is a lab, not a system that has seen real traffic. The figures ("thousands of events per second") describe the design intent, not a load actually measured in production with real customers.