Events are a simple way to say:
"this happened, and other parts of the application may care."
That is useful when you want to keep one piece of code focused on its own job instead of making it directly call every downstream action itself.
For example:
- an order is created
- a user signs up
- a password is reset
- a report finishes generating
The code that performs the main action can emit an event, and other listeners can react to it.
When to use events
Use events when:
- one action can lead to several follow-up actions
- those follow-up actions should stay loosely coupled
- the follow-up work still belongs in the same PHP process
Examples:
- send a welcome email after a user signs up
- write an audit log after an admin changes a setting
- update a read model after an order is placed
Do not use in-process events when the work must survive process restarts or be retried later. For that, record a durable event in an outbox or move the work onto a queue. See Queues and Background Jobs.
If you want a ready-made durable path in an Assegai app, import EventsOutboxModule, record messages with OrmOutboxStore, and relay them with AssegaiOutboxRelayService.
Install the package
In an Assegai project:
assegai add events
Or install it directly with Composer:
composer require assegaiphp/events
This package is designed to work outside Assegai too, so plain PHP projects can use it without assegaiphp/core.
Standalone PHP usage
If you are not inside an Assegai app, start with the emitter directly:
use Assegai\Events\EventEmitter;
$events = new EventEmitter();
$events->on('orders.created', function (array $payload): void {
// send email, update a cache, write a log...
});
$events->emit('orders.created', [
'orderId' => 42,
]);
This is synchronous. The listener runs immediately during the same PHP execution.
Assegai usage
In an Assegai app, import the events module once and inject the emitter where you want to publish events.
use Assegai\Core\Attributes\Injectable;
use Assegai\Core\Attributes\Modules\Module;
use Assegai\Core\Consumers\MiddlewareConsumer;
use Assegai\Core\Interfaces\AssegaiModuleInterface;
use Assegai\Events\Bridge\AssegaiEventEmitter;
use Assegai\Events\Bridge\EventsModule;
#[Injectable]
final class OrdersService
{
public function __construct(
private readonly AssegaiEventEmitter $events,
)
{
}
public function create(array $order): void
{
// save the order first
$this->events->emit('orders.created', $order);
}
}
#[Module(
imports: [EventsModule::class],
providers: [OrdersService::class],
)]
final class AppModule implements AssegaiModuleInterface
{
public function configure(MiddlewareConsumer $consumer): void
{
}
}
Once the module is imported, Assegai can also auto-register listener methods marked with #[OnEvent(...)].
If you also want durable event recording and relay support, import EventsOutboxModule instead of just EventsModule. It includes EventsModule and adds the outbox bridge.
Listening with #[OnEvent(...)]
Use #[OnEvent(...)] on a provider method when you want it to react to an event:
use Assegai\Core\Attributes\Injectable;
use Assegai\Events\Attributes\OnEvent;
#[Injectable]
final class OrderListener
{
#[OnEvent('orders.created')]
public function handleOrderCreated(array $payload): void
{
// send confirmation email
// update analytics
// write audit entries
}
}
That listener provider should be registered in a module like any other provider:
#[Module(
imports: [EventsModule::class],
providers: [OrdersService::class, OrderListener::class],
)]
final class OrdersModule implements AssegaiModuleInterface
{
public function configure(MiddlewareConsumer $consumer): void
{
}
}
Named events vs event objects
You can emit either:
- a named event such as
orders.created - an event object such as
new OrderCreated(...)
Named events are easy to read and easy to filter with wildcards:
$events->emit('orders.created', ['orderId' => 42]);
Event objects are useful when you want a typed payload:
final readonly class OrderCreated
{
public function __construct(
public int $orderId,
public string $customerEmail,
)
{
}
}
$events->emit(new OrderCreated(42, 'orders@example.com'));
Then a listener can receive the typed object directly:
#[OnEvent(OrderCreated::class)]
public function handle(OrderCreated $event): void
{
// $event->orderId
// $event->customerEmail
}
Wildcards
The emitter supports wildcard event names by default.
Use * to match one segment:
#[OnEvent('orders.*')]
public function handleOrderEvents(mixed $payload, string $eventName): void
{
// matches orders.created
// matches orders.cancelled
// does not match orders.delivery.failed
}
Use ** to match multiple levels:
#[OnEvent('orders.**')]
public function handleAnyOrderEvent(mixed $payload, string $eventName): void
{
// matches orders.created
// matches orders.delivery.failed
}
What gets passed to the listener
The emitter adapts the arguments based on the listener method signature:
- no parameters: nothing is passed
- one parameter: the payload or event object
- two parameters: payload/event object, then the event name
- three parameters: payload/event object, event name, then the original event object if one was emitted
Example:
#[OnEvent('orders.created')]
public function handle(array $payload, string $eventName): void
{
// $payload contains the emitted data
// $eventName is "orders.created"
}
Readiness and early events
Assegai registers #[OnEvent(...)] listeners during application bootstrap.
That means events emitted too early can be missed, especially if they are fired before bootstrap finishes.
If you need to emit during startup code, wait for the readiness watcher first:
use Assegai\Events\Bridge\AssegaiEventEmitter;
use Assegai\Events\Bridge\EventEmitterReadinessWatcherProvider;
final class StartupPublisher
{
public function __construct(
private readonly EventEmitterReadinessWatcherProvider $eventsReady,
private readonly AssegaiEventEmitter $events,
)
{
}
public function boot(): void
{
$this->eventsReady->waitUntilReady();
$this->events->emit('app.started');
}
}
Most day-to-day feature code will not need this. It mainly matters for startup-time events.
The bridge also protects you from duplicate declarative registrations if bootstrap logic runs more than once on the same app instance. A listener method on the same provider instance will only be registered once.
A practical pattern
The usual pattern looks like this:
- a service performs the main action
- the service emits a past-tense event such as
orders.created - one or more listeners react to that event
That helps keep the main service focused:
OrdersServicecreates the orderOrderEmailListenersends emailOrderAuditListenerwrites audit recordsOrderProjectionListenerupdates a read model
Those listeners do not need to know about each other.
Good naming habits
Good event names usually describe something that has already happened:
users.registeredorders.createdinvoices.sent
That reads more clearly than command-style names such as:
send.invoiceupdate.analytics
The event should describe the fact. The listener decides what to do about that fact.
What this package does not do
This package is intentionally small.
Today it gives you:
- synchronous event emission
- listener registration
#[OnEvent(...)]support- wildcard event matching
- a small Assegai bridge
It does not try to be:
- a queue system
- a distributed event bus
- an event store
- a retry engine
If you need durable background processing, record a durable event in an outbox or move the work onto a queue after the event handler decides that it should happen.
Next step
If you want the deeper details around wildcard behavior, listener readiness, package configuration, and how to keep events maintainable as the app grows, continue with Events In Depth.