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
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 — 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'));
}
}
}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.
// 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.
}
}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 — 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 { /* ... */ }
}Stateful long-running processes with compensation. Handler-chaining workflows for stateless pipelines. Saga timeouts in one #[Delayed] attribute. All declarative, all attribute-driven.
Crash-resistant multi-step processes on your existing database and broker. Sagas, orchestrators, chained workflows, outbox, and #[Delayed] timeouts — plain PHP attributes, no separate workflow service, no replay-deterministic DSL.
First-party packages for RabbitMQ, Kafka, SQS, Redis, and DBAL. Ecotone fits your existing stack — no new cluster to operate, no migration of your message infrastructure.
Rebuild a projection on a new version in parallel — concurrent async backfill partitioned by aggregate ID scales rebuilds to millions of events across N workers. The live projection keeps serving queries until the atomic flip.
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
