AW
Case study 04 · MONISCOPE

Event-driven architecture

A canonical Laravel event/listener architecture, with three details that move it past textbook: auto-discovery (no listener map), idempotency at the listener boundary (a unique-index DB row, not Redis), and queue tier separation so a flooded SMS queue can't slow audit writes.

Pre-launch — wired into MONISCOPE's dev environment. The pattern is ship-ready: domain Actions emit events, queued listeners cascade work without coupling business logic to background-job concerns. Designed to support staff workflows once tenants are onboarded.
By the numbers

What auto-discovery and the unique-index pattern give you

45
Domain events
61
Listeners auto-wired
0
Manual map entries
The engineering "why"

Six things beyond textbook events

1

Auto-discovery, no $listen map

Laravel 12 scans app/Listeners/ at boot. Any class with handle…(EventClass $event) is auto-wired. New event = no map edit, just a new listener.

snippet 01
2

Idempotency keys at the listener boundary

Every listener with side effects calls Idempotency::claim("audit:rental_created:{id}", 3600) first. Duplicate event delivery hits a unique-index violation and silently no-ops.

snippet 02
3

Idempotency uses a unique index, not Redis

A row in idempotency_keys with a unique key column. Atomic via the database — no second store, no second consistency model.

snippet 02
4

Queue tier separation

Notifications go to the notifications queue with [10, 60, 300]s backoff. Audit writes use the default queue with shorter backoff. A flooded SMS queue doesn't slow audit writes.

snippet 03
5

Listeners with failed() recover gracefully

Critical listeners (rental confirmations, lien notices, payment receipts) implement a failed() method that logs to disk and creates a staff task. Silent triple-failure isn't acceptable for those flows.

snippet 03
6

Events broadcast for real-time UI

RentalCreated implements ShouldBroadcast over the facility.{id} private channel. Admins see new rentals appear in real time without polling.

in full case study
Architecture

One Action call, multiple cascades, off-thread

HTTP request
Controller calls Action
Action::run()
DB writes (transactional)
EventName::dispatch($model)
Response returned (event work runs off-thread)
Job worker · queue:work
Auto-discovery wires every queued listener:
LogXAudit Idempotency::claim() AuditLog::log()
SendXNotifications → idempotency-claim → email + SMS
SyncXOnRental → external API call
FireAutomationTrigger AutomationEngine::fire()
ShouldBroadcast — Reverb / Pusher
Browser (Echo)
private-facility.{id} ◀ events: RentalCreated, PaymentRecorded, …
Admin dashboard reflows live (no polling)
Idempotency · queue tier separation · auto-discovery — three things that move this past textbook
The Code · how it flows

Three snippets, in execution order

Auto-discovered listeners → idempotency claim at the entry point → graceful failure recovery. Real excerpts from app/Providers/EventServiceProvider.php, app/Support/Idempotency.php, and app/Listeners/SendRentalNotifications.php.

Step 1 · How listeners get wired

Auto-discovery — EventServiceProvider is empty by design

Laravel 12 walks app/Listeners/ at boot and registers every type-hinted handle…() method. New event = new listener file. No registration map.

EventServiceProvider.php · LogRentalAudit.phpphp
// ── EventServiceProvider — empty by design ─────────────────────
namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    /**
     * Laravel 12 auto-discovers all handle* methods on listeners in app/Listeners.
     * No explicit $listen array, no Event::listen() calls.
     */
}


// ── A listener wired by type-hint, handling TWO events ─────────
namespace App\Listeners;

use App\Events\RentalCancelled;
use App\Events\RentalCreated;
use App\Models\AuditLog;
use App\Support\Idempotency;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;

class LogRentalAudit implements ShouldQueue
{
    public int $tries   = 3;
    public int $backoff = 60;

    // Auto-wired to RentalCreated event purely by the type-hint.
    public function handleCreated(RentalCreated $event): void
    {
        if (!Idempotency::claim("audit:rental_created:{$event->rental->id}", 3600)) {
            return;  // duplicate delivery → silent no-op
        }

        AuditLog::log(
            'rental_created',
            "{$event->user->name} rented Unit {$event->rental->unit_number}",
            $event->user,
            $event->user,
            [
                'rental_id'    => $event->rental->id,
                'monthly_rate' => (float) $event->rental->monthly_rate,
            ],
        );
    }

    // Auto-wired to RentalCancelled by the type-hint.
    public function handleCancelled(RentalCancelled $event): void
    {
        if (!Idempotency::claim("audit:rental_cancelled:{$event->rental->id}", 3600)) {
            return;
        }

        AuditLog::log(
            'rental_cancelled',
            "Rental cancelled for Unit {$event->rental->unit_number}",
            $event->cancelledBy,
            $event->rental->user,
            ['rental_id' => $event->rental->id],
        );
    }

    public function failed(object $event, \Throwable $exception): void
    {
        Log::error('LogRentalAudit failed', ['exception' => $exception->getMessage()]);
    }
}
Step 2 · The first line of every listener

Idempotency::claim() — race inside the database

The classic mistake is checking Cache::has then Cache::put — two workers can both pass has, both put, both run. The fix: race INSIDE the database. idempotency_keys.key has a UNIQUE constraint. The first INSERT wins; the second throws SQLSTATE 23000.

app/Support/Idempotency.phpphp
namespace App\Support;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class Idempotency
{
    /**
     * Claim a key for the given TTL. First caller wins.
     *
     * @return bool True if claimed (proceed). False if already in flight (skip).
     */
    public static function claim(string $key, int $ttlSeconds = 3600): bool
    {
        // Opportunistic cleanup — 1% of calls prune expired rows.
        if (random_int(1, 100) === 1) {
            static::pruneExpired();
        }

        try {
            // Race here. UNIQUE INDEX on `key` guarantees one winner.
            DB::table('idempotency_keys')->insert([
                'key'        => $key,
                'expires_at' => now()->addSeconds($ttlSeconds),
                'created_at' => now(),
            ]);

            return true;

        } catch (\Illuminate\Database\QueryException $e) {
            // 23000 = SQL standard "integrity constraint violation" (duplicate key).
            if ($e->getCode() === '23000') {
                $existing = DB::table('idempotency_keys')->where('key', $key)->first();

                // Reclaim if the prior claim has expired.
                // Optimistic lock on expires_at: if another worker reclaimed
                // between our SELECT and UPDATE, our UPDATE matches 0 rows.
                if ($existing && now()->greaterThan($existing->expires_at)) {
                    $updated = DB::table('idempotency_keys')
                        ->where('key', $key)
                        ->where('expires_at', $existing->expires_at)  // optimistic lock
                        ->update([
                            'expires_at' => now()->addSeconds($ttlSeconds),
                            'created_at' => now(),
                        ]);

                    return $updated > 0;
                }

                // Active claim by someone else — caller should skip.
                return false;
            }

            // Any other DB error — log + fail closed (don't double-process).
            Log::error('Idempotency claim failed unexpectedly', [
                'key'   => $key,
                'error' => $e->getMessage(),
            ]);
            return false;
        }
    }

    public static function release(string $key): void
    {
        DB::table('idempotency_keys')->where('key', $key)->delete();
    }

    public static function pruneExpired(): int
    {
        return DB::table('idempotency_keys')->where('expires_at', '<', now())->delete();
    }
}
Step 3 · When everything goes wrong

Queued listener — separate queue, retry, failed() escalation

Notifications run on a dedicated queue. $backoff arrays mean the gap between retries grows: 10s, 60s, 5min — gives transient outages time to settle. After all retries are exhausted, failed() creates a StaffTask so a human follows up.

SendRentalNotifications.phpphp
namespace App\Listeners;

use App\Events\RentalCreated;
use App\Services\NotificationService;
use App\Services\TemplateNotificationService;
use App\Support\Idempotency;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;

class SendRentalNotifications implements ShouldQueue
{
    /** Run on a separate queue from audit writes — tier separation. */
    public $queue = 'notifications';

    /** Total attempts. */
    public int $tries = 3;

    /** Per-attempt backoff. 10s → 60s → 5min — transient processor outages settle in minutes. */
    public array $backoff = [10, 60, 300];

    public function handle(RentalCreated $event): void
    {
        // Always idempotency-guard at the entry point.
        if (!Idempotency::claim("notify:rental_instructions:{$event->rental->id}", 3600)) {
            return;
        }

        $rental = $event->rental;
        $user   = $event->user;

        // Two delivery channels: NotificationService for in-app + email,
        // TemplateNotificationService for templated SMS.
        NotificationService::rentalConfirmed($rental);
        TemplateNotificationService::send('rental_instructions', $user, $rental);
    }

    /**
     * Called by Laravel after $tries attempts have all failed.
     * Don't let critical confirmations vanish — log + escalate to a human.
     */
    public function failed(object $event, \Throwable $exception): void
    {
        Log::error('SendRentalNotifications failed permanently', [
            'rental_id' => $event->rental?->id,
            'exception' => $exception->getMessage(),
        ]);

        // The full build creates a StaffTask here so a human follows up
        // with the customer — they paid for a rental, the confirmation never
        // reached them. We can't let that go quiet.
    }
}

Source

Excerpts from app/Providers/EventServiceProvider.php, app/Listeners/LogRentalAudit.php, app/Listeners/SendRentalNotifications.php, and app/Support/Idempotency.php in MONISCOPE (pre-launch). The full build also includes ShouldBroadcast events on a private facility.{id} channel — happy to walk through the broadcast layer too.