Stu Mason
Stu Mason
Guide

Event-Driven Notifications: Deduplication, Tenant Isolation, Channel Expansion

Stuart Mason7 min read

Every Laravel app starts with notifications the same way: something happens, you call `$user->notify(new SomethingHappened())`, and you move on. It works. Until it doesn't. It stops working when you

Event-Driven Notifications: Deduplication, Tenant Isolation, Channel Expansion

Every Laravel app starts with notifications the same way: something happens, you call $user->notify(new SomethingHappened()), and you move on. It works. Until it doesn't.

It stops working when you have multiple events that might trigger the same notification. When you have tenants that need isolated notification channels. When you need to add SMS without touching fifty files. When a customer gets the same email three times because three events fired within a second of each other.

I built a notification engine for a hospitality platform that had all of these problems. Here's how I solved them.

The Problem With Direct Notification Calls

The naive approach:

// In a controller or action
$booking->guest->notify(new BookingConfirmedNotification($booking));

This is fine for simple apps. But it has problems:

  1. Coupling. Your business logic knows about notifications. Every action that should trigger a notification has the notify call embedded in it.
  2. Duplication. If two different actions can confirm a booking (manual confirmation, automatic confirmation, API confirmation), each one needs the notify call. Miss one and the notification doesn't go out.
  3. No deduplication. If both manual and automatic confirmation fire within seconds (race condition, double-click, whatever), the guest gets two confirmation emails.

Event-Driven: Decouple Everything

The better approach: your business logic fires events. Subscribers listen for those events and decide what notifications to send.

// app/Actions/Booking/ConfirmBooking.php
final class ConfirmBooking
{
    public function execute(Booking $booking, User $confirmedBy): void
    {
        $booking->update([
            'status' => BookingStatus::Confirmed,
            'confirmed_by' => $confirmedBy->id,
            'confirmed_at' => now(),
        ]);

        event(new BookingConfirmed($booking));
    }
}

The action doesn't know or care about notifications. It fires an event. That's it.

// app/Events/BookingConfirmed.php
final class BookingConfirmed
{
    public function __construct(
        public readonly Booking $booking,
    ) {}
}

The Event Subscriber

A subscriber listens for events and dispatches notifications:

// app/Listeners/NotificationSubscriber.php
final class NotificationSubscriber
{
    public function __construct(
        private readonly NotificationDispatcher $dispatcher,
    ) {}

    public function subscribe(Dispatcher $events): array
    {
        return [
            BookingConfirmed::class => 'handleBookingConfirmed',
            BookingCancelled::class => 'handleBookingCancelled',
            CheckInCompleted::class => 'handleCheckIn',
            ReviewReceived::class => 'handleReviewReceived',
            PaymentFailed::class => 'handlePaymentFailed',
        ];
    }

    public function handleBookingConfirmed(BookingConfirmed $event): void
    {
        $booking = $event->booking;

        $this->dispatcher->send(
            notifiable: $booking->guest,
            notification: new BookingConfirmedNotification($booking),
            deduplicationKey: "booking_confirmed:{$booking->id}",
            tenantId: $booking->hotel_id,
        );

        $this->dispatcher->send(
            notifiable: $booking->hotel->owner,
            notification: new NewBookingNotification($booking),
            deduplicationKey: "new_booking:{$booking->id}",
            tenantId: $booking->hotel_id,
        );
    }

    // ... other handlers
}

Register the subscriber in EventServiceProvider:

protected $subscribe = [
    NotificationSubscriber::class,
];

All notification logic lives in one place. When you need to know "what notifications does a booking confirmation trigger?", you look at one method. Not five controllers.

Deduplication With Composite Keys

Here's the bit that saved us from angry customers getting duplicate emails. The NotificationDispatcher checks a deduplication cache before sending:

// app/Services/NotificationDispatcher.php
final class NotificationDispatcher
{
    public function __construct(
        private readonly Repository $cache,
    ) {}

    public function send(
        mixed $notifiable,
        Notification $notification,
        string $deduplicationKey,
        int $tenantId,
        int $windowSeconds = 300,
    ): bool {
        $compositeKey = $this->buildCompositeKey(
            $deduplicationKey,
            $notifiable,
            $tenantId,
        );

        if ($this->cache->has($compositeKey)) {
            Log::info('Notification deduplicated', [
                'key' => $compositeKey,
                'notification' => $notification::class,
            ]);

            return false;
        }

        // Lock the key for the dedup window
        $this->cache->put($compositeKey, true, $windowSeconds);

        $notifiable->notify($notification);

        return true;
    }

    private function buildCompositeKey(
        string $deduplicationKey,
        mixed $notifiable,
        int $tenantId,
    ): string {
        $notifiableKey = $notifiable->getMorphClass().':'.$notifiable->getKey();

        return "notification_dedup:{$tenantId}:{$notifiableKey}:{$deduplicationKey}";
    }
}

The composite key includes:

  • Tenant ID — Hotel A's dedup cache doesn't interfere with Hotel B's
  • Notifiable identifier — Different recipients can receive the same notification
  • Deduplication key — The specific event instance (e.g., booking_confirmed:456)

The time window (default 5 minutes) means: if the same notification would be sent to the same person for the same event within 5 minutes, it's suppressed. After 5 minutes, the cache expires and the notification can be sent again (useful for retry scenarios).

Why a Composite Key?

I tried simpler approaches first. Just the event key. Just the event key plus the user. Both had problems:

Event key only (booking_confirmed:456): If both the guest and the hotel owner should receive a notification for the same booking, the second one gets deduplicated. Wrong.

Event key plus user (booking_confirmed:456:user:12): Works for single-tenant apps. But in a multi-tenant system where the same user ID might appear in different tenant contexts, you get cross-tenant dedup collisions.

The full composite key — tenant, notifiable, event — is the only combination that's both specific enough to prevent duplicates and broad enough to not suppress legitimate notifications.

Tenant Isolation

Each hotel on the platform has its own notification preferences: which channels are enabled, which events should trigger notifications, custom branding for emails.

// app/Models/HotelNotificationConfig.php
final class HotelNotificationConfig extends Model
{
    protected function casts(): array
    {
        return [
            'enabled_channels' => 'array',
            'enabled_events' => 'array',
            'email_from_name' => 'string',
            'email_from_address' => 'string',
        ];
    }
}

The dispatcher checks tenant configuration before sending:

public function send(
    mixed $notifiable,
    Notification $notification,
    string $deduplicationKey,
    int $tenantId,
    int $windowSeconds = 300,
): bool {
    $config = HotelNotificationConfig::where('hotel_id', $tenantId)->first();

    if (! $config) {
        return false;
    }

    // Check if this event type is enabled for this tenant
    $eventType = $this->resolveEventType($notification);
    if (! in_array($eventType, $config->enabled_events)) {
        return false;
    }

    // Dedup check (as before)
    $compositeKey = $this->buildCompositeKey(
        $deduplicationKey,
        $notifiable,
        $tenantId,
    );

    if ($this->cache->has($compositeKey)) {
        return false;
    }

    $this->cache->put($compositeKey, true, $windowSeconds);

    // Filter channels based on tenant config
    $notification->setChannels(
        array_intersect(
            $notification->defaultChannels(),
            $config->enabled_channels,
        ),
    );

    $notifiable->notify($notification);

    return true;
}

Hotel A wants email and SMS. Hotel B wants email only. Hotel C wants everything including Slack. Each tenant's configuration is respected without any conditional logic in the notifications themselves.

Channel Expansion Without Rewrites

The beauty of this pattern is adding channels. When the client asked for SMS support, I didn't touch a single existing notification class. I:

  1. Created an SMS channel driver
  2. Added 'sms' to the available channels in the config
  3. Updated tenant configs to include SMS where requested

Each notification already declared its available channels:

// app/Notifications/BookingConfirmedNotification.php
final class BookingConfirmedNotification extends Notification
{
    private array $channels = ['mail', 'database'];

    public function defaultChannels(): array
    {
        return $this->channels;
    }

    public function setChannels(array $channels): void
    {
        $this->channels = $channels;
    }

    public function via(object $notifiable): array
    {
        return $this->channels;
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Booking Confirmed — {$this->booking->reference}")
            ->markdown('emails.booking.confirmed', [
                'booking' => $this->booking,
                'guest' => $notifiable,
            ]);
    }

    public function toSms(object $notifiable): SmsMessage
    {
        return new SmsMessage(
            "Your booking {$this->booking->reference} is confirmed. "
            ."Check-in: {$this->booking->check_in->format('d M Y')}."
        );
    }

    public function toArray(object $notifiable): array
    {
        return [
            'booking_id' => $this->booking->id,
            'reference' => $this->booking->reference,
            'type' => 'booking_confirmed',
        ];
    }
}

Adding the toSms method was the only change to the notification class. Everything else — the tenant config check, the dedup logic, the event subscriber — remained untouched.

Monitoring and Debugging

Every notification that passes through the dispatcher gets logged:

// After successful send
NotificationLog::create([
    'hotel_id' => $tenantId,
    'notifiable_type' => $notifiable->getMorphClass(),
    'notifiable_id' => $notifiable->getKey(),
    'notification_type' => $notification::class,
    'channels' => $notification->via($notifiable),
    'deduplication_key' => $deduplicationKey,
    'sent_at' => now(),
]);

This lets you answer questions like:

  • "Did the guest receive their confirmation email?" — Check the log.
  • "Why didn't Hotel B get a Slack notification?" — Check their config. SMS isn't enabled.
  • "Why did this notification send twice?" — Check the dedup keys. Different tenant IDs? Different time windows?

Debugging notification issues without a log is hell. With a log, it's a database query.

The Payoff

The notification engine handles about 2,000 notifications per day across 40+ hotels. Since implementing dedup, duplicate notifications dropped to zero. Adding new channels (we've added push notifications since SMS) takes an afternoon. And when a hotel says "we don't want cancellation emails, just SMS," it's a config change, not a code change.

It took about two weeks to build properly. Most of that was the dedup logic and the tenant configuration system. The event subscriber pattern itself was straightforward. But those two weeks paid for themselves within the first month of production.


I write about Laravel, AI tooling, and building software. More at stuartmason.co.uk.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.