Skip to main content

Events (RabbitMQ)

Identity emits domain events onto a shared RabbitMQ topic exchange so tenant-services can subscribe instead of polling REST. F7 introduced the publisher infrastructure; this page documents the contract every consumer needs to follow.

The audit (cross-repo #43, #44, #57, #61) called out the previous "REST-poll only" status quo as the root cause of stale permission caches that took up to an hour to invalidate after a role change. The new pipeline reduces that to seconds (broker latency).

Exchange + envelope

PropertyValue
Exchangevecton.events
Typetopic
Durabletrue
Auto-deletefalse
Delivery modepersistent (2)

Every message body is a JSON envelope:

{
"id": "identity.tenant.member_added:9f8a23ce4f1b8c12",
"type": "identity.tenant.member_added",
"service": "identity",
"occurred_at": "2026-05-08T10:23:14+00:00",
"payload": {
"tenant_id": "01h...",
"tenant_slug": "acme",
"user_id": 42,
"is_owner": false,
"actor_id": 7
}
}

The envelope ships with content_type: application/json and a message_id matching id.

Routing keys

F7 wired four routing keys onto domain events:

Routing keyEvent classPayload fields
identity.tenant.member_addedApp\Events\UserAddedToTenanttenant_id, tenant_slug, user_id, is_owner, actor_id
identity.tenant.member_removedApp\Events\UserRemovedFromTenanttenant_id, tenant_slug, user_id, actor_id
identity.user.scheduled_for_deletionApp\Events\UserScheduledForDeletionuser_id, scheduled_for_deletion
identity.policy.updatedApp\Events\PolicyUpdatedpolicy_id, policy_name, affected_user_ids[]

Subscribing to identity.* gets every identity event; binding to identity.tenant.* gets membership churn only.

Consumer pattern

A tenant-service consumer typically declares a queue and binds it to one or more routing keys:

$channel = $connection->channel();
$channel->exchange_declare('vecton.events', 'topic', false, true, false);

$channel->queue_declare('crm.identity-mirror', false, true, false, false);
$channel->queue_bind('crm.identity-mirror', 'vecton.events', 'identity.tenant.*');
$channel->queue_bind('crm.identity-mirror', 'vecton.events', 'identity.policy.updated');

$channel->basic_consume('crm.identity-mirror', '', false, false, false, false, function ($msg) {
$envelope = json_decode($msg->body, true);
handle($envelope);
$msg->ack();
});

Idempotency: each id is unique per emit. Consumers should dedupe on id if they care about exactly-once semantics — the publisher only guarantees at-least-once.

Failure model (current state)

EventPublisher::publish is best-effort:

  • Connection errors → log + return false. The request path is not blocked.
  • Broker outage → events emitted during the outage are lost unless the consumer reads from a durable queue that was bound before the outage.

This is a known F-future limitation — the audit identified an outbox pattern (DB row + dedicated worker) as the right fix. F7 chose the best-effort path because it's better than the previous "REST-poll with 1h cache" status quo and it doesn't add a writer-side dependency on RabbitMQ availability.

Event listener wiring

EventServiceProvider::$listen (F7 update):

PolicyUpdated::class => [
InvalidateUserTokensOnPolicyChange::class,
[PublishUserLifecycleToBroker::class, 'onPolicyUpdated'],
],
UserAddedToTenant::class => [
SendUserAddedToTenantNotification::class,
CancelUserDeletionOnTenantAssignment::class,
[PublishUserLifecycleToBroker::class, 'onUserAddedToTenant'],
],
UserRemovedFromTenant::class => [
SendUserRemovedFromTenantNotification::class,
[PublishUserLifecycleToBroker::class, 'onUserRemovedFromTenant'],
],
UserScheduledForDeletion::class => [
SendUserScheduledForDeletionNotification::class,
[PublishUserLifecycleToBroker::class, 'onUserScheduledForDeletion'],
],

The publisher listener is an additional subscriber alongside the email/cleanup listeners — broker availability does not affect those side effects.

Adding a new event type

  1. Define / reuse a domain event class under App\Events.
  2. Add a method on App\Listeners\PublishUserLifecycleToBroker that maps the event to a routing key + payload.
  3. Register the method as a listener in EventServiceProvider::$listen.
  4. Document the routing key + payload in the table above.
  5. Add a consumer-side handler in the relevant tenant-service.

Where to look in code

  • app/Services/EventPublisher.php — connection, envelope, publish
  • app/Listeners/PublishUserLifecycleToBroker.php — domain → routing key
  • app/Providers/EventServiceProvider.php — listener wiring
  • config/queue.php — RabbitMQ host config (the publisher reuses it)