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
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 — 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 { /* ... */ }
}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 — 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);
}
}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 — 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,
) {}
}Store events, not state. Aggregates, projections, replay, snapshotting. Partitioned + streaming projections so rebuilds parallelize across workers and catch up in real time — no single-process bottleneck.
Projections are trigger-based: on every run they read from the Event Store at their last committed position. Crash at event #42? Fix the bug, deploy, and the projection catches up automatically — no manual reset, no backfill script.
A projection can emit a downstream event the moment it applies a change — sagas, event handlers, and other projections subscribe via the normal #[EventHandler]. Rebuild a projection? Emission is automatically suppressed, so downstream consumers aren't flooded with duplicate historical events while the read model catches up.
One #[Sensitive] attribute encrypts a field in the event store, on the wire over RabbitMQ / SQS / Redis / Kafka / DBAL outbox, and in your structured logs — because all serialization flows through one shared conversion pipeline.
Aggregate publishes event. Saga subscribes by attribute. Projection subscribes by attribute. Async handler subscribes by attribute. The attribute is the wiring.
Haven’t found what you’re looking for? Contact us
Unleash the power of Messaging in PHP
and push productivity to the higher level
