Certification Authorities on Ecotone

Your product is a chain of trust that has to hold up for twenty years. An auditor wants to know which certificate was valid on which Tuesday and which committee approved which revocation — and the answer cannot depend on a database UPDATE that one of your engineers got right.

Composer package · Laravel or Symfony · PostgreSQL or MySQL · RabbitMQ, Kafka, SQS, Redis, or DBAL outbox

In production

Challenges and how Ecotone solves them

"The regulator wants the full lifecycle of certificate X, with timestamps"

A row-based "current state" table cannot answer the question "what was certificate X's state at 09:00 on 14 March 2023, and what changed it on that day". Reconstructable lifecycle is what the regulator is testing — not the current status column.

Certificate.php
// Certificate.php — every lifecycle decision is an event committed atomically

use Ecotone\EventSourcing\Attribute\EventSourcingAggregate;
use Ecotone\Modelling\Attribute\EventSourcingHandler;
use Ecotone\Modelling\Attribute\Identifier;

#[EventSourcingAggregate]
final class Certificate
{
    #[Identifier] public string $serialNumber;
    private CertificateStatus $status;
    private ?\DateTimeImmutable $revokedAt = null;

    #[CommandHandler]
    public static function issue(IssueCertificate $cmd): array
    {
        return [new CertificateIssued(
            $cmd->serialNumber, $cmd->subject, $cmd->validFrom, $cmd->validTo
        )];
    }

    #[CommandHandler]
    public function revoke(RevokeCertificate $cmd): array
    {
        if ($this->status === CertificateStatus::Revoked) {
            return []; // idempotent revoke — no duplicate event
        }
        return [new CertificateRevoked($this->serialNumber, $cmd->reason, $cmd->at)];
    }

    #[EventSourcingHandler] public function onIssued(CertificateIssued $e): void { /* ... */ }
    #[EventSourcingHandler] public function onRevoked(CertificateRevoked $e): void { /* ... */ }
}
Regulator asks for the lifecycle of serialNumber: 0xABC...Replay the stream for that aggregate. The events are the audit trail, in commit order, with timestamps.
A bug is found in the lifecycle policyFix the command handler and redeploy. History remains untouched; future commands behave correctly. Past events do not need migration.
Cross-team analytics need a current-state viewA #[Projection] derives the read model from the events. The events remain the source of truth.

"We revoked a certificate yesterday, but the CRL feed still says it's valid"

Two operators committed nearly simultaneously — one issuing a fresh certificate, one revoking an existing one. The revocation transaction started first but committed a few milliseconds later, because writing to the audit table took longer than the simpler issuance. A naive projection reads the visible event with the higher position, advances its cursor past the missing one, and never goes back. The revoked certificate stays "valid" in every downstream consumer that trusts the projection — the CRL feed, the OCSP responder, the internal dashboard. The revocation reached the audit table, but the world never found out.

CertificateStatusProjection.php
// CertificateStatusProjection.php — gap-aware position is the default for #[Projection]

use Ecotone\Projecting\Attribute\Projection;

#[Projection('certificate_status', fromStreams: ['certificates'])]
final class CertificateStatusProjection
{
    public function __construct(private CertificateReadModel $readModel) {}

    #[EventHandler]
    public function onIssued(CertificateIssued $e): void
    {
        $this->readModel->upsert($e->serialNumber, CertificateStatus::Valid, $e->validFrom);
    }

    #[EventHandler]
    public function onRevoked(CertificateRevoked $e): void
    {
        $this->readModel->setRevoked($e->serialNumber, $e->reason, $e->at);
    }
}
The slower revoke commit lands seconds after the projection has already advancedEcotone records the gap and keeps the projection moving on the events it can see. The moment the revocation becomes visible, the projection picks it up and applies it — the CRL flips to revoked, no manual reset needed.
Schema change to the read modelBlue-green rebuild — the new projection version backfills in parallel; an atomic flip cuts over with no downtime; the old projection keeps serving until then.
Projection downtime during a power outageOn restart, the projection reads its last committed position and replays from there. No manual catch-up.

"Subject DN contains PII we need to crypto-shred on subject erasure request"

Subject distinguished names (DNs) and contact attributes inside certificate metadata are PII. GDPR Article 17 grants the subject a right to erasure. Erasing rows from an event-sourced store would break the audit trail (and is the wrong shape architecturally). Crypto-shred — delete the encryption key, the ciphertext becomes unreadable — preserves the audit (the event still exists, in its place in the chain) while satisfying the erasure obligation.

CertificateIssued.php
// CertificateIssued.php — PII fields are sensitive; the rest of the event stays auditable

use Ecotone\DataProtection\Attribute\Sensitive;
use Ecotone\DataProtection\Attribute\WithEncryptionKey;

#[WithEncryptionKey(expression: "headers['subjectId']")]
final readonly class CertificateIssued
{
    public function __construct(
        public string $serialNumber,
        #[Sensitive] public string $subjectDistinguishedName,
        #[Sensitive] public string $subjectEmail,
        public \DateTimeImmutable $validFrom,
        public \DateTimeImmutable $validTo,
    ) {}
}
Subject exercises right to erasureDelete the subject's encryption key. The subjectDistinguishedName and subjectEmail fields on every event referencing the subject become unreadable in the event store, in the broker payloads, and in the structured logs — because all three serialize through the same conversion pipeline.
Regulator asks for the chainThe certificate's serial number, validity window, and revocation status remain readable. The PII fields are ciphertext — present in the chain, redacted in content.
Key rotationRe-encrypt with the new key on the next event; old events use the previous key. #[WithEncryptionKey(expression: …)] resolves the key from the subject id at write time.

Frequently asked questions

Haven’t found what you’re looking for? Contact us

Every state-changing command on a certificate aggregate produces one or more events committed atomically with the aggregate version bump. The store is append-only by design; there is no UPDATE path that can erase the history. The regulator's question and the engineer's question are answered by the same query against the same table.
Yes. The event store is your PostgreSQL or MySQL — the same database your operations team already backs up. No vendor cluster lock-in, no foreign storage format to read back in a decade. Snapshots help loading speed; the events themselves stay.
Add the new #[CommandHandler] and #[EventSourcingHandler]. Replay the affected aggregates against the new rules — Ecotone supports event versioning and upcasting so a renamed field or restructured payload remains readable.
Yes. EventStreamEmitter inside a projection handler publishes a downstream event onto the event bus; sagas and event handlers subscribe via the normal #[EventHandler]. Critically, emission is auto-suppressed during a projection rebuild — downstream consumers are not flooded with historical duplicates on backfill.

Be part of the change with EcotoneCurve

Unleash the power of Messaging in PHP
and push productivity to the higher level

Get started
Gradient
Shapes 1
Shapes 2
DiscordTwitterSupport and ContactTelegram