Vortos
Messaging

Lifecycle Hooks

Intercept key moments in the event lifecycle — before dispatch, before send, before consume, and after consume — using PHP attributes.

Lifecycle Hooks

Lifecycle hooks let you intercept specific moments in the event processing pipeline without modifying the framework or adding middleware. Each hook type fires at a precise point — on the producer side, on the consumer side, or both.

Hook Types

AttributeFiresSide
#[BeforeDispatch]Before EventBus routes the eventProducer (backend)
#[AfterDispatch]After EventBus completes (or fails)Producer (backend)
#[PreSend]Before the event is produced to KafkaProducer (backend)
#[BeforeConsume]Before any handler runs for a messageConsumer (worker)
#[AfterConsume]After all handlers complete for a messageConsumer (worker)
#[BeforeHandler]Immediately before each individual handlerConsumer (worker)
#[AfterHandler]Immediately after each individual handler resolvesConsumer (worker)

How Discovery Works

At container compile time, HookDiscoveryCompilerPass scans all services tagged vortos.hook. The tag is applied automatically by MessagingExtension to any class with a hook attribute. The pass builds an array of hook descriptors keyed by hook type, stores them in the vortos.hooks container parameter, and populates vortos.hook_locator with the actual service references.

At runtime, HookRegistry reconstructs typed HookDescriptor objects from the stored arrays. HookRunner reads from the registry and invokes hooks at the correct moment.

Zero Runtime Overhead

All discovery and wiring happens at compile time. At runtime, HookRunner just reads a pre-built array and calls services — no reflection, no scanning.

Writing a Hook

A hook is a class with one or more hook attributes and an __invoke method. All hooks receive an EventEnvelope — the wrapper that carries both the POPO payload and the framework metadata:

use Vortos\Domain\Event\EventEnvelope;
use Vortos\Messaging\Hook\Attribute\BeforeDispatch;
use Psr\Log\LoggerInterface;

#[BeforeDispatch]
final class AuditDispatchHook
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    public function __invoke(EventEnvelope $envelope): void
    {
        $this->logger->info('Event dispatching', [
            'type'        => $envelope->payloadType,
            'aggregateId' => $envelope->aggregateId,
        ]);
    }
}

No registration needed. Vortos discovers it automatically.

Hook Signatures

Each hook type receives different arguments:

#[BeforeDispatch]
final class MyHook
{
    public function __invoke(EventEnvelope $envelope): void
    {
        // fires before EventBus routes the event
        // $envelope->payload is your POPO
        // $envelope->aggregateId, ->occurredAt, ->metadata available
    }
}
#[AfterDispatch]
final class MyHook
{
    public function __invoke(EventEnvelope $envelope, ?\Throwable $throwable): void
    {
        // $throwable is null on success, contains exception on failure
        if ($throwable !== null) {
            // dispatch failed
        }
    }
}
#[PreSend]
final class MyHook
{
    public function __invoke(EventEnvelope $envelope, array &$headers): void
    {
        // fires just before producing to Kafka
        // $headers is passed by reference — you can add/modify Kafka message headers
        $headers['x-schema-version'] = (string) $envelope->schemaVersion;
        $headers['x-tenant-id']      = $envelope->metadata->tenantId ?? '';
    }
}
#[BeforeConsume]
final class MyHook
{
    public function __invoke(EventEnvelope $envelope): void
    {
        // fires in the worker, before the handler runs
    }
}
#[AfterConsume]
final class MyHook
{
    public function __invoke(EventEnvelope $envelope, ?\Throwable $throwable): void
    {
        // fires after all handlers complete for a message
        // $throwable is null on success
    }
}
#[BeforeHandler]
final class MyHook
{
    public function __invoke(EventEnvelope $envelope, string $consumerName, string $handlerId): void
    {
        // fires once per handler, immediately before it runs
        // fires even for handlers that will be skipped (idempotency, replay limit)
        // guaranteed to have a matching AfterHandler call
    }
}
use Vortos\Messaging\Hook\HandlerOutcome;

#[AfterHandler]
final class MyHook
{
    public function __invoke(
        EventEnvelope  $envelope,
        string         $consumerName,
        string         $handlerId,
        HandlerOutcome $outcome,
        int            $attempts,
        float          $latencyMs,
        ?\Throwable    $throwable = null,
    ): void {
        // fires after each individual handler resolves
        // default: fires for all terminal outcomes except AttemptFailed
    }
}

HandlerOutcome values:

CaseMeaning
SucceededHandler ran and committed on the first attempt
SucceededAfterRetriesHandler eventually succeeded after one or more retries
SkippedIdempotentDuplicate message — idempotency key already set, handler skipped
DiscardedReplayLimitReplay limit exceeded — message discarded without running handler
DeadLetteredAll retries exhausted — message written to dead letter store
AttemptFailedIntermediate retry failure — opt-in only, not fired by default

Filter by outcome using on::

use Vortos\Messaging\Hook\HandlerOutcome;

// only fires when a handler is dead-lettered or replay-discarded
#[AfterHandler(on: HandlerOutcome::TERMINAL_FAILURE)]
final class AlertOnFailureHook
{
    public function __invoke(
        EventEnvelope $envelope,
        string $consumerName,
        string $handlerId,
        HandlerOutcome $outcome,
        int $attempts,
        float $latencyMs,
        ?\Throwable $throwable = null,
    ): void {
        // page your on-call team
    }
}

// opt in to intermediate retry attempts
#[AfterHandler(on: [HandlerOutcome::AttemptFailed])]
final class RetryMetricsHook { ... }

Built-in outcome groups:

ConstantIncludes
HandlerOutcome::TERMINAL_SUCCESSSucceeded, SucceededAfterRetries
HandlerOutcome::TERMINAL_FAILUREDeadLettered, DiscardedReplayLimit
HandlerOutcome::TERMINALAll of the above + SkippedIdempotent (everything except AttemptFailed)

Filtering by Event or Consumer

Hooks fire for all events by default. You can narrow scope using event and consumer filters:

// only fires for UserRegistered events
#[BeforeDispatch(event: UserRegistered::class)]
final class UserRegistrationAuditHook
{
    public function __invoke(EventEnvelope $envelope): void {}
}

// only fires for handlers in the 'orders' consumer
#[BeforeConsume(consumer: 'orders')]
final class OrderConsumerHook
{
    public function __invoke(EventEnvelope $envelope): void {}
}

// only fires on failure
#[AfterConsume(onFailureOnly: true)]
final class FailureAlertHook
{
    public function __invoke(EventEnvelope $envelope, ?\Throwable $throwable): void
    {
        // alert your on-call team
    }
}

Priority

Multiple hooks of the same type run in priority order — higher priority runs first:

#[BeforeDispatch(priority: 100)]
final class FirstHook { ... }

#[BeforeDispatch(priority: 50)]
final class SecondHook { ... }

Error Isolation

Hook failures never crash the pipeline. Each hook invocation is wrapped individually — if one hook throws, the error is logged and the next hook continues:

BeforeDispatch hooks
    ├── AuditHook::__invoke() → throws → logged, continues
    ├── TracingHook::__invoke() → succeeds
    └── MetricsHook::__invoke() → succeeds

EventBus routing continues normally

This prevents a broken hook from blocking event dispatch or handler execution.

Execution Order in the Full Pipeline

EventBus::dispatch(envelope)

    ├── [BeforeDispatch hooks]

    ├── Route to producer
    │       └── [PreSend hooks]  ← modify Kafka headers here
    │               └── produce to Kafka / write to outbox

    └── [AfterDispatch hooks]

Worker ConsumerRunner

    ├── Deserialize → build EventEnvelope

    ├── [BeforeConsume hooks]

    ├── For each handler:
    │       ├── [BeforeHandler hooks]
    │       ├── MiddlewareStack → Handler::__invoke()
    │       └── [AfterHandler hooks]  ← outcome, attempts, latencyMs

    └── [AfterConsume hooks]

On this page