Skip to content

feat: [#730] add Listen and Dispatch to the event module - #1541

Merged
hwbrzzl merged 9 commits into
goravel:masterfrom
ahmed3mar:feat/event-listen-dispatch
Sep 2, 2026
Merged

feat: [#730] add Listen and Dispatch to the event module#1541
hwbrzzl merged 9 commits into
goravel:masterfrom
ahmed3mar:feat/event-listen-dispatch

Conversation

@ahmed3mar

@ahmed3mar ahmed3mar commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

📑 Description

Closes goravel/goravel#730

Reimplementation of #1133 from scratch against current master, addressing the review comments left on that PR.

This adds a Laravel style event dispatcher — Listen() and Dispatch() — next to the existing Register() / Job() flow, which is now deprecated but keeps working.

Listen

// string events and wildcard patterns
facades.Event().Listen("user.created", &SendWelcomeMail{})
facades.Event().Listen("user.*", func(evt any, args ...any) error {
    log.Info("user event fired:", evt)
    return nil
})

// event values, and slices of any accepted form
facades.Event().Listen(&UserCreated{}, &SendWelcomeMail{}, &NotifyAdmin{})
facades.Event().Listen([]string{"user.created", "user.updated"}, &AuditLogger{})

// typed closure, the event is resolved from the parameter type
facades.Event().Listen(func(evt *UserCreated) error {
    return mail.Send(evt.User)
})

Dispatch

result := facades.Event().Dispatch(&UserCreated{User: user}, []event.Arg{
    {Type: "string", Value: user.Email},
})

if result.Failed() {
    return result.Error() // every listener error, joined
}

Every listener runs, and event.Result carries the errors of the ones that failed:

type Result interface {
	Error() error   // all the errors joined into one, nil when none failed
	Errors() []error
	Failed() bool
}

Listener interface

There is one listener interface. It replaces the old one rather than sitting beside it, so Listen and the deprecated Register take the same type:

type Listener interface {
	// Handle the event. A Listener receives only the canonical event name, never
	// the event object itself. Any event data it needs must travel in args so that
	// it behaves the same in process and through the queue.
	Handle(eventName string, args ...any) error
	// Queue configure the event queue options, the listener is pushed onto the
	// queue instead of running synchronously when Queue().Enable is true.
	Queue(args ...any) Queue
	// Signature returns the unique identifier for the listener.
	Signature() string
}

Queue().Enable decides whether the listener is pushed onto the queue or run in process, so no separate ShouldQueue concept is introduced. Listeners must be non-nil pointers: the queue resolves a job by its signature alone, and two values of the same type cannot be told apart, so a value listener could be enqueued and a different one executed.

Design notes

  • The event name, not the event object, crosses every boundary. queue/utils/convert.go converts only scalars, so an event object cannot survive the queue. Rather than hand the listener an object in process and a string from a worker — a first argument whose type depends on the transport — a Listener always receives the name. Data a queued listener needs travels in args. Note this is not Laravel's behaviour: Laravel serialises the event object, which this queue cannot do.
  • Wildcards are matched, not cached. Patterns are kept in registration order and matched by an allocation free matcher, so overlapping patterns fire deterministically and an unfamiliar event name costs the same as a familiar one. Benchmarks ship in event/application_dispatch_benchmark_test.go.
  • One dispatch pipeline. Dispatch and the deprecated Task differ only in the two behaviours that genuinely differ: Task requires listeners and stops at the first error, so a failing listener still prevents the jobs behind it from being queued; Dispatch runs everything and collects. An event.Event prepares its payload in both, once, and only when a listener exists to prepare it for.
  • Reflection is confined to closures. A Listener and a func(evt any, args ...any) error are matched by type assertion and invoked directly. Typed func(e *UserCreated) error closures need one reflect.Call; they are validated at registration and rejected on an event they could never be called for.
  • Panics become errors: a listener, a queued job and an event's own Handle are each contained, so one faulty implementation fails itself instead of unwinding the caller or killing a queue worker.
  • Errors are all declared in errors/list.go.

Migration

event.Listener changes shape, so every existing listener needs a mechanical edit. The steps below are written to be applied automatically.

1. Handle takes the event name. Add a leading eventName string parameter to every type implementing event.Listener.

// before
func (r *SendWelcomeMail) Handle(args ...any) error {
	return mail.To(args[0].(string)).Send()
}

// after
func (r *SendWelcomeMail) Handle(eventName string, args ...any) error {
	return mail.To(args[0].(string)).Send()
}

Signature() and Queue() are unchanged. The payload keeps its positions: what used to be args[0] is still args[0].

2. Non-queued listeners registered through Register now run in process. A listener whose Queue().Enable is false used to be handed to the queue facade and run through its sync driver; it is now called directly. Behaviour to check for: anything that relied on the queue's job wrapping, its failure recording, or a custom sync driver's instrumentation for these listeners.

3. Job resolves listeners by event name. A fresh event instance now finds the listeners registered for that event, where it previously matched by the identity of the value and so only worked for zero-size structs.

facades.Event().Register(map[event.Event][]event.Listener{
	&OrderShipped{ID: 1}: {&NotifyCustomer{}},
})

// before: no listeners found, the value differs
// after:  resolved by event name, the listeners run
facades.Event().Job(&OrderShipped{ID: 2}, args).Dispatch()

4. Type-derived event names are fully qualified. An event that is not a string is named by its import path plus its type name, github.com/acme/app/events.UserCreated, so two packages both called events no longer collide. That name is what a wildcard pattern is matched against, what a Listener receives as eventName, and the first argument of a queued job. Anything that hardcoded the short events.UserCreated form must be updated.

5. Listen validates strictly, and returns an error rather than registering something that could never fire. Applies to: closures, whose parameter must be a named struct or a pointer to one, and which cannot be registered on an event they do not name; slice kinds other than []string, []event.Event and []any; events that are neither a non-empty string nor a named struct; nil or non-pointer listeners; and listeners whose Signature() is empty. Check the returned error at every call site.

6. Deploy note — drain the event queues first. A queued event job pushed by a pre-upgrade binary carries no event name, and the new worker reads the first argument as one. Let the event queues empty before rolling out, or the in-flight jobs are misread.

Backward compatibility

  • Register, Job, GetEvents and event.Task keep working, now marked Deprecated:.
  • Listeners registered through Register are reachable by Dispatch, and both paths run through the same pipeline.
  • event.Listener is a breaking change, see the migration above.
  • Instance gains Listen and Dispatch. That is source breaking for anything outside the framework that implements event.Instance; callers are unaffected.

CI

The Test In Example jobs fail, and will keep failing until a companion PR lands: goravel/example implements the old Handle(args ...any) error, so it no longer satisfies event.Listener. That PR will be opened separately. Every job that builds only this repository is green.

Open question for the maintainer

make:listener generates the new event.Listener, but WithEvents is func() map[event.Event][]event.Listener, whose key is an event.Event, so it cannot express a string event, a wildcard pattern or a closure. A generated listener does fit it now that there is a single interface, but the rest of the Listen surface does not. Options:

  1. Deprecate WithEvents and add WithEventListeners(func(dispatcher event.Instance) error), run at the event configuration lifecycle point, legacy registrations first, a returned error failing bootstrap.
  2. Leave the builder alone for now and ship the bootstrap path separately.

Happy to do either; say which you prefer.

✅ Checks

  • Added test cases for my code

@ahmed3mar
ahmed3mar requested a review from a team as a code owner August 30, 2026 21:53
@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.30872% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.60%. Comparing base (fda6f87) to head (22d951e).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
event/application_listen.go 96.36% 6 Missing ⚠️
event/application.go 94.44% 3 Missing ⚠️
event/task.go 92.59% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1541      +/-   ##
==========================================
+ Coverage   72.41%   72.60%   +0.19%     
==========================================
  Files         409      412       +3     
  Lines       26475    26741     +266     
==========================================
+ Hits        19172    19416     +244     
- Misses       7301     7323      +22     
  Partials        2        2              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ahmed3mar
ahmed3mar force-pushed the feat/event-listen-dispatch branch 2 times, most recently from 00ce3a2 to 489ddad Compare August 30, 2026 22:00
Introduce a Laravel style event dispatcher next to the existing Register and
Job flow, which is now deprecated but keeps working unchanged.

Listen registers one or more listeners for one or more events, accepting
strings, wildcard patterns, event.Event values and slices of any of them.
Listeners can be event.QueueListener implementations, plain
func(event any, args ...any) error closures, or typed
func(event *SomeEvent) error closures whose event is resolved from the
parameter type.

Dispatch fires an event, runs every matching listener and returns an
event.Result carrying the errors of the ones that failed, joined through
Error(), listed by Errors() and summarised by Failed().

Every accepted listener form is normalised once at registration into a single
internal representation, so dispatching itself needs neither reflection nor
type switches. Listeners implementing event.QueueListener are registered with
the queue once per signature and pushed onto it when Queue().Enable is true,
carrying the event name as their first argument because only scalars survive
the queue boundary. A panicking listener fails on its own without breaking
the rest of the dispatch.

Also update the make:listener stub to the new interface.
@ahmed3mar
ahmed3mar force-pushed the feat/event-listen-dispatch branch from 489ddad to 3f89025 Compare August 30, 2026 22:14
…h flow

QueueListener.Handle now takes the canonical event name rather than the event
itself. Previously it received the event when running in process and the name
when running from the queue, so the type of its first argument depended on the
transport, and Queue() can depend on the payload, so one listener could
alternate between the two at runtime. The data a queued listener needs travels
in args, since only scalars survive the queue boundary.

The wildcard cache is gone. It stored an entry for every dispatched event name,
including when no wildcard was registered at all, so an application using
dynamic event names grew a map for the lifetime of the process. Wildcards are
now kept in registration order and matched with an allocation free matcher,
which also makes the invocation order of overlapping patterns deterministic
rather than dependent on map iteration. Dispatching an event name never seen
before now costs the same as dispatching a familiar one.

Dispatch and the deprecated Task share one pipeline, parameterised by the two
behaviours that genuinely differ: Task requires listeners and stops at the
first error, so that a failing listener still prevents the jobs behind it from
being queued, while Dispatch runs everything and collects. An event.Event
prepares its payload in both, once, and only when there is a listener to
prepare it for.

Two listeners can no longer claim one queue signature. The queue resolves a job
by signature alone, so the second registration would silently run the first
listener's code. Listen now rejects the conflict, and rejects value listeners
outright, since two values of the same type cannot be told apart.

A panic inside a queue worker no longer escapes: queueJob.Handle recovers so
the failure goes through the queue's own retry and failure handling instead of
taking the process down.

Also: queue registration no longer runs while the registry is locked, Listen
validates a whole request before registering any of it, and a typed closure can
no longer be registered on an event it could never be called for.
@goravel-coder

Copy link
Copy Markdown
Contributor

Review — event Listen / Dispatch

Reviewed across safety, logic, performance, style, testing, and architecture. No Must Fix items — the core logic is correct, Laravel-style, and well-tested. (matchWildcard was verified against the str.Is reference oracle; go test -race ./event/... and the benchmarks run clean.)

Note: these findings were produced by automated review. Some may be false positives or intentional trade-offs — please double-check each one when fixing, and push back on anything you disagree with.

Should Fix

  1. Register holds app.mu with no deferred unlock while calling user code → permanent deadlock on panicevent/application.go:74-110
    app.mu.Lock() (74) is released manually at 110, but listener.Signature() (84, 86) — user code — runs in between. A panicking Signature() leaves the mutex held forever, blocking every later Listen/Dispatch/Register/GetEvents/Job. Extract the locked body into a helper that defers the unlock; keep app.queue.Register(jobs) outside the lock.

  2. Dispatch(evt any, args ...[]event.Arg) silently drops payloads past the firstcontracts/event/events.go:5, event/application_dispatch.go:36-39
    Dispatch(evt, p1, p2) compiles and ignores p2. Consider a single []Arg param (or validate len(args) <= 1).

  3. The event's own Handle is not panic-protected, unlike listenersevent/application_dispatch.go:60-67
    e.Handle(args) runs unprotected while callListener and queueJob.Handle both recover. A panicking event.Handle escapes Dispatch and Task.Dispatch.

  4. queueJob.Handle's recover re-enters user code, and Signature() is called in the error pathsevent/application_listen.go:61-73
    If Handle panics, the recover calls j.listener.Signature() — more user code; a panicking Signature() defeats the recovery. Signature() is also called at 68 and 73. Capture it once into a local above the defer.

  5. v.Signature() invoked on a listener already rejected as nil/non-pointerevent/application_listen.go:225-227
    EventListenerNotPointer.Args(v.Signature()) evaluates Signature() even when value.IsNil() is true; a dereferencing Signature() panics in the error path.

  6. eventNames default case silently accepts undocumented slice typesevent/application_listen.go:327-331
    Listen([]*userCreated{...}) falls through to getEventName, registering a listener under the garbage name []*event.userCreated with no error. Consider rejecting slice types other than []string/[]event.Event/[]any.

  7. Result leaks its internal sliceevent/result.go:16-26
    NewResult stores the caller's []error verbatim and Errors() returns r.errs directly. Inconsistent with GetEvents() which defensively clones.

  8. Benchmark never exercises the wildcard match pathevent/application_dispatch_benchmark_test.go:19-53
    Patterns are "segmentN.*" but both subtests dispatch "user.created"/"user.N", so the match branch and the append(listeners, wildcard.listeners...) allocation are never measured. Add matching patterns ("user.*""user.created"). Also precompute the "user."+strconv.Itoa(i) names so allocs reflect dispatch, not harness string building.

  9. Test coverage gaps

    • Fail-fast (dispatchModeTask.stopOnError) has no multi-listener test — task_test.go only registers one listener, so the break at application_dispatch.go:73-75 is a no-op.
    • closureEventName variadic (t.IsVariadic()) and zero-arity (NumIn() != 1) branches untested — application_listen.go:302.
    • TestApplication_ListenCollectsEveryError never exercises the multi-error stderrors.Join path — application_listen_test.go:261.

Nits

  • QueueListener is a misleading name — it runs synchronously whenever Queue().Enable is false; it's the default listener. Consider Listener vs LegacyListener. — contracts/event/events.go:36
  • registered map[string]any + listenerIdentity dedup is fragile — pointer identity gives false EventQueueDuplicateSignature for factory-built listeners; Register bypasses claimQueueJob. — application_listen.go:185-209, application.go:106-108
  • listener struct mixes three bool flags (legacy/wildcard/withEvent). — application_listen.go:18-37
  • Import alias inconsistencyapplication_listen.go:9 imports contracts/queue unaliased while the other files use contractsqueue.
  • EventListenerEventMismatch out of alphabetical order in the Event group. — errors/list.go:129
  • NewResult lacks a doc comment (or unexport it). — event/result.go:16
  • Stale "wildcard cache" test names/comments contradict the "matched, not cached" implementation. — application_dispatch_test.go:60,68,350
  • Contract doc omits the typed-closure form func(event *SomeEvent) error. — contracts/event/events.go:9-13
  • eventArgsToQueueArgs has no capacity hint (task.go:94-104); a synchronously-executed QueueListener calls argValues twice per dispatch (application_dispatch.go:93 + application_listen.go:231).
  • event.Event.Handle compat shim creates dual behavior — a listener receives different args depending on whether the dispatched value implements event.Event. — application_dispatch.go:60-67

Laravel parity

The new API matches Laravel's listen/dispatch shape (string/array/wildcard events, typed closures inferring the event from the parameter type, object events keyed by class name, *-only wildcard semantics). The documented deviations — QueueListener receiving the event name rather than the object, wildcards matched-not-cached, and error-aggregating Result instead of a response array — are all reasonable Go adaptations and are clearly documented in the PR. The one thing to flag for a future PR: Laravel (and Goravel's own notification/broadcast modules) decide queueing via a ShouldQueue marker interface, not a Queue().Enable field.

@hwbrzzl

hwbrzzl commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@ahmed3mar About Handle(eventName string, args ...any) error, it can be a breaking change, we can fully optimize the related logic. So QueueListener may be unnecessary, it's acceptable to modify Listener directly. Then we will add doc to ask AI agents to make the modification in users' projects automatically.

Listener now takes the canonical event name, and QueueListener is gone, so
there is a single listener interface behind both Listen and the deprecated
Register:

	Handle(eventName string, args ...any) error

A listener no longer satisfies queue.Job, so Register wraps each one the way
Listen already did, which means both flows put the same job on the queue with
the event name as its first argument.

Queueing is now decided the same way everywhere: Queue().Enable pushes the
listener onto the queue, otherwise it runs in process. A listener registered
through Register used to run through the queue facade even when it did not
enable queueing, that round trip is gone.

Register resolves the signatures and builds the wrappers before locking the
registry. Resolving a signature calls into the listener, and doing so under the
lock meant a panicking listener left the mutex held for the rest of the
process.

Wildcard patterns keep one entry per registration. Folding a repeated pattern
into its earlier entry ran its later listeners before the patterns registered
in between.
eventArgsToQueueArgs lost its last caller when the queue arguments were unified
behind queueArgs, which always prepends the event name.
…the event error list

A queue job now carries the signature it was built with, so the worker and its
panic recovery report a job without calling back into the listener. A listener
whose Signature panics can no longer defeat the recovery meant to contain it.

The signature travels from the job to everything that needs it, rather than
being asked of the listener again at each step.

The event errors are named for the cases the module is growing into, and the
block is back in alphabetical order.
An event that panics while preparing its payload is now contained the same way
a panicking listener is, so a faulty event fails its own dispatch instead of
unwinding the caller. Its listeners do not run, the payload was never prepared.

Dispatch reports a second payload rather than silently dropping it.

Job builds its task from the same registry Dispatch uses, so it reaches the
listeners registered through Listen and the ones registered on a matching
wildcard pattern. It used to look them up by the identity of the event value,
which only ever found them when the event was a zero size struct.

The queue branch that ran a job synchronously is gone, a listener that doesn't
enable queueing never reached it.

The wildcard benchmark now registers patterns that actually match the names it
dispatches, and builds those names before the loop, so it measures dispatching
rather than the harness.

@hwbrzzl hwbrzzl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Great job 👍 Could you add full tests in goravel/example as well?

@hwbrzzl
hwbrzzl merged commit dc98961 into goravel:master Sep 2, 2026
13 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Event refactor

3 participants