|
| 1 | +--- |
| 2 | +paths: |
| 3 | + - 'app/Listeners/**' |
| 4 | + - 'app/Events/**' |
| 5 | +--- |
| 6 | + |
| 7 | +# Listeners & Events |
| 8 | + |
| 9 | +## No EventServiceProvider — auto-discovery is by exact string, not by class |
| 10 | + |
| 11 | +This app has no `EventServiceProvider`. Laravel auto-discovers listeners by scanning |
| 12 | +`app/Listeners/*::handle()` for a type-hinted event parameter, then caches the map in |
| 13 | +`bootstrap/cache/events.php` keyed by the **literal string** it found in your source |
| 14 | +(the `use` import / type hint), not by the resolved class. |
| 15 | + |
| 16 | +PHP class name resolution is case-insensitive, so a typo like |
| 17 | +`use App\Events\CheckoutableCheckedin;` (lowercase `in`) still "works" at runtime — but |
| 18 | +it produces a *different* array key than the real `App\Events\CheckoutableCheckedIn`, |
| 19 | +because PHP array keys are case-sensitive strings. The listener silently registers under |
| 20 | +a dead key and never fires for the real event, with no error anywhere. |
| 21 | + |
| 22 | +- When adding or renaming a listener, match the event's `use` import and `handle()` |
| 23 | + type hint character-for-character against the event class's actual declared name. |
| 24 | +- After touching anything in `app/Listeners/` or `app/Events/`, run |
| 25 | + `php artisan event:clear` (or `optimize:clear`) so a stale cache can't paper over a |
| 26 | + typo you just introduced or just fixed. |
| 27 | +- If a listener "isn't firing" and there's no obvious reason, check |
| 28 | + `bootstrap/cache/events.php` for a duplicate/near-duplicate key before anything else. |
| 29 | + |
| 30 | +## Dedup side effects across listeners on the same event with a property on the event |
| 31 | + |
| 32 | +Several classes (`CheckoutableCheckedOut`, `CheckoutableCheckedIn`, ...) fan out to |
| 33 | +multiple listeners (`*EmailNotification`, `*WebhookNotification`, `*LogCheckin`, ...). |
| 34 | +None of these implement `ShouldQueue`, so Laravel dispatches the exact same event |
| 35 | +*instance* to each of them in turn within one synchronous dispatch. |
| 36 | + |
| 37 | +When more than one listener needs the result of a side-effecting action (e.g. creating |
| 38 | +a `CheckoutAcceptance` row) for the same event, don't let each listener call the |
| 39 | +side-effecting action independently — that duplicates the effect once per listener. |
| 40 | +Instead, cache the result on a public property of the event itself, e.g. |
| 41 | +`$event->checkoutAcceptance`, and have each listener check-then-set: |
| 42 | + |
| 43 | +```php |
| 44 | +if (! $event->checkoutAcceptance) { |
| 45 | + $event->checkoutAcceptance = CreateCheckoutAcceptanceAction::run(...); |
| 46 | +} |
| 47 | +return $event->checkoutAcceptance; |
| 48 | +``` |
| 49 | + |
| 50 | +This only works because the listeners themselves are synchronous and share the literal |
| 51 | +event object — `getCheckoutAcceptance()` runs entirely inside `handle()`, before any |
| 52 | +`Notification`/`Mail` object is built or sent, so it's unaffected by queueing on those |
| 53 | +downstream classes. Many `app/Notifications/*` already implement `ShouldQueue`, and |
| 54 | +`app/Mail/*` Mailables are trending that way too — that's fine and doesn't touch this |
| 55 | +caching at all, because by the time a `ShouldQueue` notification/mailable is handed off |
| 56 | +to the queue, it already has the resolved `CheckoutAcceptance` baked into its |
| 57 | +constructor; only *delivery* is deferred, not the acceptance lookup. |
| 58 | + |
| 59 | +What WOULD break this: adding `ShouldQueue` to one of the *Listener* classes |
| 60 | +themselves (e.g. `CheckoutableCheckedOutEmailNotification implements ShouldQueue`). |
| 61 | +That's a different, much bigger change than queueing a Notification/Mailable — it would |
| 62 | +give each listener its own independently-serialized copy of `$event`, dispatched to |
| 63 | +possibly-concurrent workers, and this caching would silently stop deduping (or worse, |
| 64 | +race). If that's ever proposed, move the idempotency into |
| 65 | +`CreateCheckoutAcceptanceAction` itself (check-for-existing-pending-then-create inside |
| 66 | +a transaction/lock) instead of relying on the event instance. |
0 commit comments