Signals & Listeners
A decoupled event system. A method subscribes with #[Listener], the
build generates a typed dispatcher, and the code that fires an event never learns who
is listening.
Listening to an event
Annotate a public static method with #[Listener] and the name
of the event. One method can listen to several by passing more names:
use Framework\Discovery\Attr\Listener;
class UserHooks {
#[Listener("userCreated")]
public static function sendWelcome(int $credentialID): void {
// …
}
#[Listener("userCreated", "userUpdated")]
public static function reindex(int $credentialID): void {
// …
}
}
The event name is just a string — nothing declares it up front. The first listener creates it, and the trigger for it appears after the next build.
void. It is a notification, not a question — the code that fired
the event carries on regardless, and a
PHPStan rule enforces the return type.Merged parameters
Each listener declares only the arguments it needs, and the generator merges them into one signature — matching by name, so a parameter shared by several listeners is passed once.
Take an event with three listeners in three different modules:
class Hospitality {
#[Listener("contactDeleted")]
public static function contactDeleted(int $contactID): void {}
}
class Conversation {
#[Listener("contactDeleted")]
public static function contactDeleted(int $contactID): void {}
}
class AutomationDispatch {
#[Listener("contactDeleted")]
public static function contactDeleted(ContactEntity $oldContact): void {}
}
Two of them want the id and the third wants the whole entity. The generated trigger takes the union of both, and hands each listener only what it asked for:
Signal::contactDeleted($contact, $contact->id);
So a listener never has to accept parameters it does not use, and adding one that needs something new simply
widens the trigger at the next build. Parameter names ending in IDs are typed as
list<int>.
Triggering with the Signal class
The build gathers every event and generates a Signal class in src/System with one
static method per event. Calling it invokes each subscribed listener with the arguments it declared:
use Framework\System\Signal;
// Runs UserHooks::sendWelcome() and UserHooks::reindex()
Signal::userCreated(42);
// Only UserHooks::reindex() listens to this one
Signal::userUpdated(42);
Two things follow from it being generated. An event with no listeners has no method — firing something nobody handles is a build error rather than a call into nothing. And a new listener needs a build before the trigger reflects it, which is the usual reason a listener does not seem to run.
Listeners of one event run in the order they were discovered, and nothing is passed back — if you need a result, you need a direct call, not a signal.
Signal::userCreated(42) calls every listener right there, one after another, and only
returns once the last one has finished. The request waits for all of them, so a slow listener makes the whole
request slow, and an exception in one stops the rest from running.That is what you want for the cleanup a delete needs — it must happen before the response goes out. For work that is slow or may fail on its own, do not do it in the listener: have the listener enqueue it, and let a queue flushed by a command do the work.
A worked example
The clearest use is cleanup that crosses modules. Deleting a contact has to touch conversations, automations and whatever else grows later — but the contact module should not have to know about any of them.
Each module handles its own side, in its own file:
// src/Conversation/Conversation.php
class Conversation {
#[Listener("contactDeleted")]
public static function contactDeleted(int $contactID): void {
self::removeForContact($contactID);
}
}
// src/Automation/AutomationDispatch.php
class AutomationDispatch {
#[Listener("contactDeleted")]
public static function contactDeleted(ContactEntity $oldContact): void {
self::cancelScheduled($oldContact);
}
}
And the contact module just announces what happened:
// src/Contact/Contact.php
class Contact {
public static function delete(int $contactID): bool {
$contact = self::getByID($contactID);
self::deleteEntity($contactID);
Signal::contactDeleted($contact, $contact->id);
return true;
}
}
Adding a module later means adding one listener to it — Contact::delete() is never touched. That
is the whole point: the dependency points towards the contact, never away from it.
When to use one
| Use a signal when | Call directly when |
|---|---|
| Several modules react to the same fact. | One place does one thing. |
| The list of reactions will grow. | The call is part of the operation itself. |
| The caller should not depend on the reactors. | You need a return value. |
| Reacting is optional — nothing breaks without it. | Failure must stop the operation. |
Deleting, merging and status changes are the events that earn a signal, because each tends to acquire new reactions over time. A single validation or a lookup does not — it is simpler, and clearer, as a method call.
userCreated does not need to know
who listens. See how Emails and
Notifications react to events this way.