ForgeEvents

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.

Overview

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.

What it gives you

  • Event dispatcher with listeners
  • Attribute-based events & listeners
  • Delayed and queued dispatch
  • Pluggable queue drivers
  • A background worker command
  • Retries, priorities & job metadata

Prerequisite: ForgeEvents requires ForgeDatabaseSQL, which the default database queue uses for its job table.

When to Reach for It

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.

Installation

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.

Defining an Event

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.

Listeners

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.

Dispatching

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.

Queued Work

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.

Recurring & Scheduled Jobs

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.

Queue Drivers

The queue is pluggable. Pick the driver that fits where your app runs and how much it needs to survive restarts:

  • database (default) — jobs live in a table, surviving restarts; the best choice for most apps.
  • file — jobs written to disk; simple, no database involved.
  • memory — jobs held in memory; fast but lost on restart, handy for tests and short-lived runs.

You choose the driver with the QUEUE_DRIVER environment variable.

Running the Worker

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.

Configuration

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