Public Transport Subscriptions on Ecotone

Millions of passengers, a renewal at 02:00 every night, and a Java estate that has been running on Kafka since long before your PHP service joined the cluster. The subscription lifecycle has to fire on its own clock, speak the existing Kafka topics, and let the reporting team replay fifty million events without taking the system offline.

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

In production

Challenges and how Ecotone solves them

"Renew the subscription one day before expiry; terminate cleanly on the expiry day"

Recurring lifecycle events are exactly what cron is bad at: cron knows time, not subscription state, so every cron loop has to re-query the universe to find the subscriptions to renew today — and races every other consumer doing the same. A #[Saga] with #[Delayed] schedules the renewal as a message, scoped to the subscription's identifier, with no cron.

SubscriptionSaga.php
// SubscriptionSaga.php — the lifecycle begins inside the saga itself

use Ecotone\Modelling\Attribute\Saga;
use Ecotone\Modelling\Attribute\Identifier;
use Ecotone\Modelling\Attribute\CommandHandler;
use Ecotone\Modelling\Attribute\EventHandler;
use Ecotone\Modelling\WithEvents;
use Ecotone\Messaging\Scheduling\Delayed;

#[Saga]
final class SubscriptionSaga
{
    use WithEvents;

    private SubscriptionStatus $status = SubscriptionStatus::Active;

    private function __construct(
        #[Identifier] private string     $subscriptionId,
        private \DateTimeImmutable      $expiresAt,
    ) {
        // The saga's own lifecycle is the first event it produces.
        $this->recordThat(new SubscriptionCreated($subscriptionId, $expiresAt));
    }

    #[CommandHandler]
    public static function create(CreateSubscription $command): self
    {
        return new self($command->subscriptionId, $command->expiresAt);
    }

    // One day before expiry — try to renew
    #[Delayed(expression: "payload.expiresAt.modify('-1 day')")]
    #[EventHandler]
    public function renew(SubscriptionCreated $event, CommandBus $bus): void
    {
        if ($this->status === SubscriptionStatus::Active) {
            $bus->send(new AttemptRenewal($this->subscriptionId));
        }
    }

    // At expiry — terminate if still active
    #[Delayed(expression: "payload.expiresAt")]
    #[EventHandler]
    public function expire(SubscriptionCreated $event): void
    {
        if ($this->status === SubscriptionStatus::Active) {
            $this->status = SubscriptionStatus::Expired;
            $this->recordThat(new SubscriptionTerminated($this->subscriptionId, 'Expired'));
        }
    }
}
A subscription is cancelled mid-monthstatus moves to Cancelled; the scheduled renew handler checks status and no-ops; no cron entry needs cleanup.
Worker restart between renewal scheduling and renewal firingThe delayed message lives in the broker's delayed-delivery primitive (RabbitMQ delayed exchange, SQS message timer, Redis sorted set); recovery is automatic on next consumer.
Renewal payment failsAttemptRenewal emits a RenewalFailed event; the saga reacts and schedules a retry or termination, depending on policy.

"Java services publish lifecycle events on Kafka — our PHP services need to consume them"

Cross-language messaging fails on three boundaries: payload format (Java's serializer ≠ PHP's), topic conventions that grew up before your PHP service existed, and resilience semantics (retry, dead-letter, idempotency) that the Java side already settled. Each PHP service ends up with its own bridging code: a Kafka client, a JSON-to-DTO converter, a hand-rolled retry loop, a dead-letter table. Multiplied across services, the bridges become the system. Ecotone collapses the bridge into attributes on the consumer method itself.

SubscriptionEventConsumer.php
// Consume the Java-produced topic directly; retry and DLQ are attributes,
// not bridging code each PHP service has to re-implement.

use Ecotone\Kafka\Attribute\KafkaConsumer;
use Ecotone\Messaging\Attribute\Retry\InstantRetry;
use Ecotone\Messaging\Attribute\ErrorChannel;

final class SubscriptionEventConsumer
{
    #[KafkaConsumer(endpointId: 'php_subscriptions', topics: ['subscription.activated.v1'])]
    #[InstantRetry(retryTimes: 3, exceptions: [DatabaseConnectionFailure::class])]
    #[ErrorChannel('dbal_dead_letter')]
    public function onActivated(SubscriptionActivated $event): void
    {
        // Activate the PHP side: kick off the welcome saga, update the read
        // model, publish an internal domain event — your code, not a bridge.
    }
}
Java service publishes subscription.activated.v1 on KafkaPHP service consumes via Distributed Bus; the named-event resolution maps the payload to the PHP DTO; the handler runs.
A new PHP service needs the same eventRegister it on the Service Map; no change in the Java publisher. The topology is declarative.
Java publisher renames a fieldVersion the named event (v2) and add an upcaster; old events still consumed by the legacy v1 handler.
Per-handler failure isolation across servicesIf one PHP consumer of SubscriptionActivated fails, only that consumer retries; sibling consumers on the same event are unaffected.

"Reporting projection needs to rebuild 50 million events after a model change"

A single-cursor projection rebuilds in O(N) wall time. At nationwide scale that means hours to days of rebuild — during which the read model is stale and the operations team is on call. Partitioned projections distribute the rebuild across N workers, parallelizing the work by aggregate id.

SubscriptionUsageProjection.php
// SubscriptionUsageProjection.php — partitioned by aggregate, rebuilds in parallel

use Ecotone\Projecting\Attribute\ProjectionV2;
use Ecotone\Projecting\Attribute\FromAggregateStream;
use Ecotone\Projecting\Attribute\Partitioned;

#[ProjectionV2('subscription_usage')]
#[FromAggregateStream(Subscription::class)]
#[Partitioned(partitions: 60)]
final class SubscriptionUsageProjection
{
    #[EventHandler] public function onActivated(SubscriptionActivated $e): void { /* ... */ }
    #[EventHandler] public function onRenewed(SubscriptionRenewed $e): void   { /* ... */ }
    #[EventHandler] public function onExpired(SubscriptionExpired $e): void   { /* ... */ }
}
Schema change requires a full rebuild60 workers consume their partitions in parallel; a 14-hour single-cursor rebuild becomes minutes.
Live projection during rebuildBlue-green model — the old projection serves queries while the new one backfills; atomic flip cuts over.
Hot streams move to KafkaStreaming projections consume directly from the broker; the rebuild story is the same.

Frequently asked questions

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

Ecotone has first-party packages for Kafka, RabbitMQ, SQS, Redis, and Dbal — the broker is your existing infrastructure, not a new cluster. Workers are PHP CLI processes scheduled the same way you schedule any other queue worker. Nothing new for the SRE team to operate.
Each broker's native delayed-delivery primitive does the waiting — RabbitMQ delayed exchange, SQS message timer, Redis sorted set, Dbal scheduler. The #[Delayed] attribute is broker-agnostic; the implementation is per-channel.
Ecotone propagates correlation and causation headers automatically — every event emitted from a handler inherits the inbound message's correlation id, and OpenTelemetry traces stitch the cross-service flow end-to-end without manual stamping.
Kafka retains the offset; the consumer resumes from the last committed offset on restart. On the PHP side, Ecotone's retry policy + DBAL dead-letter handle the per-message resilience.

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