zetacomponents/signal-slot
zetacomponents/signal-slot provides a lightweight signal/slot implementation for PHP. Connect callbacks to named signals and emit them to decouple components, enabling clean event-driven code without heavy dependencies.
Install
composer require zetacomponents/signal-slot
There is no Laravel auto-discovery, no service provider, and no facade — this is a standalone eZ/Zeta Components library, not a Laravel package. You wire it up yourself.
Look here first
src/ezcSignalCollection.php — the whole API surface you will actually use (connect(), disconnect(), emit(), isConnected()).src/ezcSignalStaticConnections.php — global/static wiring, the part most likely to bite you.src/ezcSignalCollectionOptions.php — the small options bag passed to the constructor.tests/ — the fastest way to learn exact call signatures because the documentation is thin and dated.First use case
Decouple one class from its listeners without dragging in Laravel's dispatcher (useful when porting legacy eZ Publish / Zeta code into a Laravel app):
use ezcSignalCollection;
$signals = new ezcSignalCollection();
$signals->connect('order.placed', fn($order) => logger("Order {$order->id} placed"));
$signals->emit('order.placed', $order);
That is the entire mental model: a named signal, a callable slot, and emit() to fire it. Think "Qt signals/slots in PHP", which is exactly what it was modeled on.
Register it in Laravel so the rest of your app can resolve it:
// app/Providers/SignalSlotServiceProvider.php
public function register(): void
{
$this->app->singleton(ezcSignalCollection::class, fn() => new ezcSignalCollection('app'));
}
Bind it as a singleton only if you want one global bus; bind it scoped() or as a plain bind() if you want isolated signal scopes per request/job.
Per-service signal collections (the pattern the library actually wants)
Do not create one god bus. Give each domain object (or small domain) its own collection and expose it:
class CheckoutService
{
public function __construct(private ezcSignalCollection $signals) {}
public function place(Order $order): void
{
// ... work ...
$this->signals->emit('checkout.placed', $order);
}
}
Register listeners in a service provider, or co-locate them where the service is constructed.
Closures vs. [$object, 'method'] callables
Both work and are stored as-is:
$signals->connect('user.registered', [$listener, 'onRegistered']);
$signals->connect('user.registered', function ($user) { /* ... */ });
Prefer [$object, 'method'] when you need disconnect() later — closures cannot be reliably compared, so disconnecting an anonymous function is fragile.
Static/cross-cutting connections
ezcSignalStaticConnections is a process-wide singleton. It is the mechanism for listeners that must apply regardless of who instantiated the collection:
use ezcSignalStaticConnections;
ezcSignalStaticConnections::getInstance()
->connect('CheckoutService', 'checkout.placed', $auditLogger);
The first argument is the signal collection name you passed to the ezcSignalCollection constructor. If you construct with new ezcSignalCollection('CheckoutService'), those static connections are merged in at construction — which is why naming your collections matters. Skip the name and your static connections will silently never fire.
Laravel integration pattern: bridge to native events
The pragmatic day-to-day move is to make SignalSlot emit into Laravel's dispatcher (or vice versa), so you get one integration point instead of two systems:
$this->app->singleton(ezcSignalCollection::class, function ($app) {
$signals = new ezcSignalCollection('app');
$signals->connect('user.registered', fn($user) =>
$app['events']->dispatch(new UserRegistered($user))
);
return $signals;
});
Now your team keeps using Event::listen()/queued listeners, and only the legacy-facing code touches ezcSignalCollection.
Workflow for porting legacy eZ code
ezcSignalStaticConnections::getInstance()->connect(...) in the legacy app; those are your real integration points.Testing pattern
Instantiate a fresh ezcSignalCollection per test and inject it. Avoid the static singleton in tests — it leaks across test cases (see Gotchas).
The static singleton is global mutable state.
ezcSignalStaticConnections::getInstance() persists for the whole PHP process. In a long-running worker (Octane, queues, Pest/PHPUnit with process reuse) listeners accumulate across "requests". Reset it between tests, or avoid it entirely and use instance connections.
Constructor name is load-bearing.
new ezcSignalCollection() with no argument means static connections for that instance are not applied (or applied under an empty name, depending on version). Always pass an explicit name if you intend to use static connections — silently missing listeners is the #1 debugging trap here.
No PSR-14, no Laravel, no $event object convention.
Listener signatures are whatever emit() passes — a bare argument list, not an event object. That means no Event::fake(), no listener discovery, no queueing, no ShouldQueue, no wildcard listening, and no automatic model-event integration. You are getting ~200 lines of callback registry; weigh that against Illuminate\Events\Dispatcher and Symfony's EventDispatcher, both of which you already have.
License metadata reads NOASSERTION.
Zeta Components was historically Apache-2.0, but the NOASSERTION tag means Composer/your SBOM tool cannot confirm it. Read LICENSE in the package before shipping to production, and check with whoever owns compliance.
It is effectively an abandoned package.
Three stars, zero dependents, no recent releases. Assume no upstream fixes. Budget for vendoring or forking if a PHP minor version breaks it, and check it against your target PHP version before committing — Zeta-era code predates strict types and modern deprecations, so expect notices on PHP 8.x.
Exception behavior on emit().
Slots are called synchronously in connection order; an exception thrown in one slot aborts the remaining slots. There is no try/catch wrapping, no async, no error isolation. Wrap your own listeners if a failure must not kill the emit loop.
Disconnecting closures is unreliable.
disconnect('signal', $closure) compares by identity and generally will not match what you registered. Use method arrays or a named invokable object you can hold a reference to.
Signal name typos fail silently.
connect('user.registerd', ...) succeeds; emit('user.registered', ...) just fires nothing (and returns falsy). Centralize signal names as constants on the emitting class — this is the single highest-value habit with this library.
Extension point if you need one.
Subclass ezcSignalCollection to add logging in emit() or to enforce a signal-name whitelist. There is no middleware/interceptor hook, so subclassing is the supported extension route. Do not subclass the static connections class — the singleton will defeat your override.
How can I help you explore Laravel packages today?