An event and queue system your app uses to decouple work from the request/response moment — fire an event, handle it where you like, and push slow work onto a background queue.
ForgeEvents is a communication component. It gives your app two related things: an event dispatcher for publishing and listening to events, and a queue for deferring work out of the current request. Because events and listeners are discovered automatically from both your app and your selected modules, everything stays loosely coupled — the thing that fires an event never needs to know who's listening.
Prerequisite: ForgeEvents requires ForgeDatabaseSQL, which the default database queue uses for its job table.
Use events to separate concerns that don't need to
happen in lockstep. Rather than calling an
email sender and a logger directly in your
checkout code, your app dispatches an
OrderPlaced event and each concern
listens on its own. This keeps your business code
focused and makes it easy to add — or remove —
behavior without touching the caller.
Reach for the queue when work shouldn't block the response at all: sending email, generating a PDF, resizing an image, syncing with an external service. Queue that work and let it run in the background.
php forge.php package:install-module --module=ForgeEvents
Adding the module wires the dispatcher into the container and starts discovering your events and listeners automatically — no manual registration required.
In your app, an event is just a class carrying
whatever data the listeners need. Mark it with the
Event attribute to configure its queue
behavior:
use Modules\ForgeEvents\Attributes\Event;
#[Event(queue: 'default', delay: '0s', maxRetries: 3, retryDelay: 1000)]
final class OrderPlaced
{
public function __construct(
public readonly int $orderId,
) {}
}
The Event attribute options give you
the queue name, the delay before first processing
(a human string like 10m,
30s, or 2h), how many
times a failed job retries, and the delay between
retries in milliseconds.
A listener is a class with a method annotated
EventListener, pointing at the event
it handles. ForgeEvents discovers these from your
app and your modules, so you just write them:
use Modules\ForgeEvents\Attributes\EventListener;
final readonly class SendOrderEmail
{
public function __construct(
private Mailer $mailer,
) {}
#[EventListener(OrderPlaced::class)]
public function handle(OrderPlaced $event): void
{
$this->mailer->send($event->orderId);
}
}
Because the listener is built from the container,
its dependencies are injected for you. A single
event can have many listeners, and a single
listener can declare multiple
EventListener methods for different
events.
From anywhere in your app you have access to the dispatcher. Fire the event and listeners run synchronously, right there:
use Forge\Core\Contracts\EventDispatcherInterface;
public function checkout(EventDispatcherInterface $events): mixed
{
// ... order logic ...
$events->dispatch(new OrderPlaced(orderId: $order->id()));
return redirect('/orders/' . $order->id());
}
Use the interface from the container rather than reaching for a global, so your code stays easy to test — swap in a fake dispatcher and assert on what was dispatched.
For work that shouldn't run during the request,
push it onto a queue. You can delay dispatch by an
amount of milliseconds, or configure an event to be
queue-backed from the start via its
Event/Queue attribute.
use Forge\Core\Contracts\EventDispatcherInterface;
public function generate(EventDispatcherInterface $events): mixed
{
// Run off the request path, 10 seconds from now
$events->dispatchDelayed(new RenderReport(userId: $userId), 10_000);
return 'Queued';
}
Queue priorities (high, normal, low) and per-job retries are handled for you, and jobs carry metadata you can read and write — including marking a job failed and releasing it back onto the queue with a retry delay.
Some events behave like a cron job. Instead of a one-shot dispatch, you schedule an event to run on a repeating interval — maintenance routines, health checks, report aggregation, and the like — so your app keeps a periodic process alive without an external scheduler.
The pattern is a self-rescheduling
event: the listener does its work, then
dispatches the same event again with a delay for
the next run. Because each run lives on the queue,
it works the same as any other job — it survives
restarts with a durable driver, shows a
scheduled status while it waits, and
is processed by the same worker.
use Modules\ForgeEvents\Attributes\Event;
use Modules\ForgeEvents\Attributes\EventListener;
use Modules\ForgeEvents\Enums\QueuePriority;
use Forge\Core\Contracts\EventDispatcherInterface;
#[Event(queue: 'maintenance', priority: QueuePriority::LOW)]
final class RunCleanup {}
#[EventListener(RunCleanup::class)]
final class CleanupListener
{
public function __construct(private EventDispatcherInterface $events) {}
public function handle(RunCleanup $event): void
{
// do the periodic work...
cleanupExpiredSessions();
// schedule the next run, 30 minutes from now
$this->events->dispatchDelayed(new RunCleanup(), 30 * 60 * 1000);
}
}
dispatchDelayed() is the piece that
makes this possible — it queues an event with an
explicit delay in milliseconds, overriding the
event's declared delay. Listeners
also have access to the current job
(currentJobId()) so they can record
progress or failures onto the live job before
scheduling the next run.
The queue is pluggable. Pick the driver that fits where your app runs and how much it needs to survive restarts:
You choose the driver with the
QUEUE_DRIVER environment variable.
Queued work is processed by a long-running worker. In your deployment you start one (or more) that pulls jobs off the configured queues:
# Process the default queue with one worker
php forge.php modules:queue:work
# More workers, and choose queues explicitly
php forge.php modules:queue:work --workers=4 --queues=default,email
Without a worker, queued jobs simply wait in the queue. In production you'd run this under a process supervisor (systemd, a container manager, or a scheduler) so it stays alive and restarts on failure. The worker service also tracks and restarts workers and tails their output, which you can drive from an admin surface in your app.
Tune behavior with two environment variables, both with sensible defaults:
# Which queue backend to use: database, file, or memory
QUEUE_DRIVER=database
# Queues the worker processes (comma-separated)
QUEUE_LIST=default,email