SignalHub

Canvas includes SignalHub, a Qt-inspired signal-slot system for decoupled event handling in PHP. Objects emit signals and connect them to handlers (slots) without tight coupling between components.

explanation

Core Concepts

  • Signal — An event source connected to one or more slots. Declared as a typed property on a class and instantiated in the constructor.
  • Slot — Any callable (closure, method, function) that receives a signal's arguments when emitted.
  • SignalHub — A centralized registry that tracks named signals across the application. Discovers object-owned signals via reflection and supports manually registered standalone signals.

Adding Signals to a Class

Declare public Signal properties and initialize them in the constructor:

use Quellabs\SignalHub\Signal;

class Button {
    public Signal $clicked;
    public Signal $hovered;

    public function __construct() {
        $this->clicked = new Signal('clicked', $this);
        $this->hovered = new Signal('hovered', $this);
    }

    public function click(int $x, int $y): void {
        $this->clicked->emit($x, $y);
    }
}

The first argument to new Signal() is the signal's name (used for hub registration and lookup). The second, optional argument is the owning object.

Connecting Slots

Connect any callable to a signal using connect():

$button = new Button();

// Closure
$button->clicked->connect(function(int $x, int $y) {
    echo "Clicked at {$x}, {$y}";
});

// Object method
$button->clicked->connect([$handler, 'onClicked']);

// Static method
$button->clicked->connect(['MyClass', 'staticHandler']);

// Named function
$button->clicked->connect('myHandlerFunction');

Slots execute in priority order, highest first. The default priority is 0:

$signal->connect($lowPriorityHandler, priority: -10);
$signal->connect($normalHandler);          // priority: 0
$signal->connect($highPriorityHandler, priority: 100);

Connecting the same callable twice is a no-op — duplicate connections are silently ignored.

Emitting Signals

Call emit() with any arguments you want to pass to connected slots:

$button->clicked->emit(42, 17);

Disconnecting Slots

Remove a specific slot from a signal:

$button->clicked->disconnect($myHandler);
// Returns true if the handler was found and removed, false otherwise

Anonymous closures assigned to a variable can be disconnected. Inline closures passed directly to connect() cannot be referenced later for disconnection.

The SignalHub Registry

The SignalHub provides centralized discovery and management of named signals. Access the application-wide instance via the locator:

use Quellabs\SignalHub\SignalHubLocator;

$hub = SignalHubLocator::getInstance();

Canvas's dependency injection container is aware of SignalHub. Any class that type-hints SignalHub in its constructor receives the application-wide instance automatically — no locator calls needed inside the container.

Discovering Object Signals

Call discoverSignals() to register all Signal-typed properties on an object with the hub. The hub uses reflection to find them, caching results per class so reflection only runs once:

$button = new Button();
$hub->discoverSignals($button);

Calling discoverSignals() twice on the same object throws a RuntimeException. All Signal properties must be initialized before discovery — an uninitialized property also throws.

When an object is done (e.g. at the end of a request), unregister its signals explicitly:

$hub->unregisterSignals($button);

Because SignalHub stores object signals in a WeakMap, they are garbage collected automatically when the owning object goes out of scope. Explicit unregistration is still recommended so that meta-signal listeners are notified and connections are cleaned up promptly.

Looking Up Signals

Retrieve a signal by name scoped to a specific owner:

// Direct object reference — O(1) lookup
$signal = $hub->getSignal('clicked', $button);

// Class or interface name — scans all registered objects for a match
$signal = $hub->getSignal('clicked', Button::class);

// No owner — searches standalone signals only
$signal = $hub->getSignal('app.ready');

Search for signals using wildcard patterns:

// All standalone signals starting with "data."
$signals = $hub->findSignals('data.*');

// All signals on a specific object instance
$signals = $hub->findSignals('*', $button);

// All signals on any object of a given class or interface
$signals = $hub->findSignals('*', Button::class);

One instance per class: The hub expects only one active instance of any given class at a time. When looking up signals by class name, getSignal() returns the first match found. Register distinct signal names on distinct instances if you need to differentiate them.

Standalone Signals

Signals don't need to belong to an object. Register them directly with the hub for application-wide events:

$appReady = new Signal('app.ready');
$hub->registerSignal($appReady);

// Retrieve and connect from anywhere
$hub->getSignal('app.ready')->connect(function(string $env) {
    echo "App started in {$env} mode";
});

$appReady->emit('production');

A standalone signal must have a name. Registering two standalone signals with the same name throws a RuntimeException. Unlike object signals, standalone signals are not garbage collected automatically — remove them explicitly when no longer needed:

$hub->unregisterSignal($appReady); // Returns true if found and removed

Hub Meta-Events

The hub emits its own signals when signals are registered or unregistered. Connect to these to monitor hub activity, implement audit logging, or auto-connect to signals as they appear:

$hub->signalRegistered()->connect(function(Signal $signal) {
    echo "New signal registered: " . $signal->getName();
});

$hub->signalUnregistered()->connect(function(Signal $signal) {
    echo "Signal removed: " . $signal->getName();
});

Meta-events fire for both object signals (via discoverSignals() / unregisterSignals()) and standalone signals (via registerSignal() / unregisterSignal()).

Introspection

Inspect signals at runtime:

$signal->getName();           // 'clicked', or null if unnamed
$signal->getOwner();          // The owning object, or null
$signal->countConnections();  // Number of currently connected slots

Testing

Inject a fresh hub during tests to isolate signal behavior:

// In test setup
SignalHubLocator::setInstance(new SignalHub());

// Reset after test
SignalHubLocator::setInstance(null);