Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Event Laravel Package

sabre/event

sabre/event is a lightweight PHP library for event-based development, offering an event emitter, promises, an event loop, and coroutines. Requires PHP 8.2. Install with composer require sabre/event.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and pin to your PHP floor. sabre/event 6.1 requires PHP 8.2; Laravel 11/12 already sit there, but Laravel 10 projects on 8.1 must stay on the 6.0.x line:

composer require sabre/event:^6.1   # PHP 8.2+
composer require sabre/event:^6.0   # PHP 7.4 – 8.1
php -r "echo PHP_VERSION;"          # confirm before you commit

Where to look first — everything is in vendor/sabre/event/lib/ (PSR-4 root for Sabre\Event\), and the whole package is under ~2k lines, so read the source rather than hunting for docs:

  • Emitter.php / EmitterTrait.php / WildcardEmitter.php — the event side, start here.
  • Promise.php + functions.php — Sabre\Event\Promise\{resolve, reject, all, race} and Sabre\Event\coroutine().
  • Loop/Loop.php — the reactor (run, stop, tick, setTimeout, setInterval, nextTick, addReadStream, addWriteStream).

The website (sabre.io/event/) is the prose reference; the CHANGELOG in the repo explains the BC breaks that matter (v3 made promises loop-driven, v4 added WildcardEmitter, v5 gave coroutines return).

First use case: a self-contained emitter inside one class. Do not replace Laravel's dispatcher — use a local emitter when several collaborators must react to something in-process, synchronously, with ordering you control, and you don't want to pollute the global event bus or hit the queue:

namespace App\Support;

use Sabre\Event\Emitter;
use Sabre\Event\EmitterInterface;

final class ImportPipeline
{
    private EmitterInterface $emitter;

    public function __construct()
    {
        $this->emitter = new Emitter();

        // Lower number = runs first. Default priority is 100.
        $this->emitter->on('row.parsed',  fn (array $row) => $this->validate($row), 10);
        $this->emitter->on('row.parsed',  fn (array $row) => $this->normalise($row), 20);
        $this->emitter->on('row.parsed',  fn (array $row) => $this->persist($row), 30);
    }

    public function rowParsed(array $row): void
    {
        $this->emitter->emit('row.parsed', [$row]);
    }
}

Bind it in a service provider ($this->app->singleton(ImportPipeline::class)) and call it from a queued job or an Artisan command. That's the 80% case: no new infrastructure, no global state, testable in isolation.

Smoke test the three primitives before building on them:

use Sabre\Event\Emitter;
use Sabre\Event\Promise;
use function Sabre\Event\coroutine;
use function Sabre\Event\Loop\run;

$e = new Emitter();
$e->on('ping', fn () => print("pong\n"));
$e->emit('ping');

coroutine(function () {
    $a = yield Promise\resolve(1);
    $b = yield Promise\resolve(2);
    return $a + $b;
})->then(fn ($v) => print("sum=$v\n"));

run(); // "pong" then "sum=3"

If you see pong but never sum=3, you've already met the package's biggest gotcha — see below.

Implementation Patterns

1) Trait-based emitters on domain objects. EmitterTrait gives any class on/once/removeListener/removeAllListeners/listeners/emit without inheritance. Good for a StreamWrapper-style class, a parser, or a driver where you want callers to hook into lifecycle moments (beforeWrite, afterFlush) but you don't want a Laravel event class per moment:

use Sabre\Event\EmitterInterface;
use Sabre\Event\EmitterTrait;

class CsvWriter implements EmitterInterface
{
    use EmitterTrait;

    public function write(array $row): void
    {
        if (false === $this->emit('beforeWrite', [$row])) {
            return; // a listener vetoed the write
        }
        // ... fwrite
    }
}

Returning false from a listener breaks the chain — that's the idiomatic cancellation/validation mechanism, cheaper than throwing.

2) Wildcard routing for plugin/hook systems. WildcardEmitter lets a listener subscribe to order.* or even *. This is the pattern for user-configurable webhooks or a plugin registry where the set of event names is data, not code:

use Sabre\Event\WildcardEmitter;

$hooks = new WildcardEmitter();
foreach ($tenant->webhookEvents() as $pattern) {      // e.g. ['invoice.*', 'user.deleted']
    $hooks->on($pattern, fn (string $name, array $payload) => $this->deliver($tenant, $name, $payload));
}
$hooks->emit('invoice.paid', ['invoice.paid', $invoice->toArray()]);

Note the listener receives the arguments you pass — emit does not inject the event name, so pass it explicitly if listeners need it.

3) coroutine for sequential-looking async in Artisan commands. This is where the package earns its keep in a Laravel codebase. Instead of nesting then() callbacks to call three APIs and merge results:

use function Sabre\Event\coroutine;

$report = coroutine(function () use ($ids) {
    $users    = yield $this->fetchAsync("/users?ids=" . implode(',', $ids));
    $orders   = yield $this->fetchAsync("/orders?user=" . $users[0]['id']);
    $invoices = yield $this->fetchAsync("/invoices?user=" . $users[0]['id']);

    return compact('users', 'orders', 'invoices');
});

$data = $report->wait(); // blocks, drives the loop, throws on rejection

Each yield suspends; the loop resumes when the promise settles. return inside the generator becomes the resolved value (v5+). For true fan-out use Promise\all([...]) — it resolves immediately for an empty array (fixed in 5.0.2):

$all = \Sabre\Event\Promise\all(array_map(fn ($u) => $this->fetchAsync($u), $urls));
$results = $all->wait();

4) Adapter pattern for third-party promises. Guzzle/React promises aren't Sabre\Event\Promise, but they're shape-compatible enough to wrap:

use Sabre\Event\Promise;

function fromGuzzle(\GuzzleHttp\Promise\PromiseInterface $p): Promise
{
    return new Promise(function ($resolve, $reject) use ($p) {
        $p->then($resolve, $reject);
    });
}

Now Guzzle's EachPromise/Utils::all output can be yielded inside a coroutine. Do this once in a helper class; don't sprinkle it.

5) Event loop for real stream I/O, not for HTTP requests. The loop is for things Laravel has no answer for: tailing a log, holding a socket open, a small custom TCP/WebSocket listener in php artisan serve:custom. Loop\addReadStream($socket, fn ($stream) => ...) + Loop\run() inside an Artisan command is the whole server. Never call Loop\run() from a controller or middleware — you'll block the request until the loop drains, which may be never.

6) Bridging both directions with Laravel's dispatcher. Keep the two systems honest:

// Laravel -> sabre
Event::listen(OrderShipped::class, fn ($e) => $hooks->emit('order.shipped', [$e->order]));

// sabre -> Laravel (so queued listeners still work)
$hooks->on('order.shipped', fn ($order) => OrderShipped::dispatch($order));

Pick one direction per integration point so you can reason about ordering. sabre's emit is synchronous; Laravel's queued listeners are not. Don't assume a sabre listener has run before a queued job picks up the same event.

7) Priorities as a pipeline DSL. Lower value runs first, default 100, ties are not guaranteed stable across versions — give explicit numbers spaced by 10 so you can insert steps later. This makes a validate → transform → persist → audit chain declarative and reorderable without touching the class.

Gotchas and Tips

Promises are inert until the loop ticks. then() callbacks are queued on the event loop, not called inline. In a Laravel HTTP context there's no loop running, so this silently does nothing:

Promise\resolve(1)->then(fn ($v) => Log::info($v)); // never logs

Fix: end chains with ->wait() (which runs the loop for you) in sync code, or Loop\run() if you're driving several. This is the #1 source of "my test passes but the callback never fires" confusion.

wait() inside a loop callback can deadlock. wait() calls Loop\run(). Calling it from inside a listener or a setTimeout callback re-enters a static singleton, and if the awaited promise depends on the outer loop progressing, it hangs. Rule: wait() only at the top of a command/job/test; yield inside coroutines instead.

The loop is global static state — fatal under Octane and in long test suites. Timers, intervals, and stream watchers survive across requests in a persistent worker. Always Loop\removeReadStream($stream) when done, keep a handle for every setTimeout, and in tearDown() stop the loop and clear your listeners. If you see a worker's memory climbing or callbacks firing twice per request, this is it.

Removing a listener during emit doesn't affect the current emit. The listener list is snapshotted at emit time. To actually short-circuit, return false from the listener rather than trying to deregister a sibling.

Priority direction is backwards from most queue systems. Lower number = earlier. If your "before" listener runs after your "after" listener, check you didn't assume higher-wins.

Rejections must carry a Throwable. Since v4, Promise\reject('nope') is invalid. Also, unhandled rejections are silent — there's no global unhandled-rejection hook. Always terminate chains with ->otherwise(fn (\Throwable $e) => ...) or wrap in try/catch around wait() (which rethrows).

Exceptions diverge by context. A throw inside an emit listener propagates synchronously to the emit() caller; a throw inside a then callback becomes a rejection passed down the chain. Two different failure modes for the same-looking code — be explicit about which one you're inside.

Never block inside a loop callback. sleep(), a sync Guzzle call, a heavy Eloquent query — all stall the entire loop, including timers that were supposed to fire. Do the I/O through the promise layer and keep callbacks CPU-only. There are no threads here; the loop is cooperative.

Closure capture leaks in long-running workers. Listeners registered per-iteration on a shared singleton emitter accumulate and hold references to models. Register once (in the constructor/provider) or removeListener/removeAllListeners explicitly at the end of each unit of work.

Version-pinning traps. Upgrading to v6 adds parameter/return types — if you extend Emitter or Promise rather than compose them, you must add matching type declarations. And because 6.1 dropped PHP 8.1, a composer update on a Laravel 10 project can yank the package; pin ^6.0 there.

Debugging toolkit: $emitter->listeners('name') to inspect what's actually registered; Loop\tick() to step exactly one iteration when you need to see where a chain stalls; var_dump inside the coroutine generator to check whether a yield received a promise or a plain value (yielding a non-promise just returns it — silent no-op). Log inside listeners, not around them, or you'll misattribute timing.

Performance: EmitterTrait is faster than WildcardEmitter (string matching per emit). For hot paths emitting thousands of times per second, prefer the plain Emitter and register concrete event names. For most Laravel workloads this is irrelevant — pick wildcards for flexibility and revisit only with a profile in hand.

Extension points worth knowing: the EmitterInterface (implement it and swap in your own emitter, e.g. a PSR-14 adapter), and the fact that Loop is a class of static methods you can't inject — which is exactly why you should keep loop-touching code confined to Artisan commands and jobs, behind an interface you can fake in tests.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource