Signal Wiring
Canvas automatically discovers signals on controllers and wires them to listeners declared by signal listeners. No manual registration or hub interaction is required in application code.
Declaring Signals on a Controller
Declare public Signal properties and give each an explicit name.
The name is the contract that listeners use to identify which signals they handle:
use Quellabs\SignalHub\Signal;
class MollieController extends BaseController {
public Signal $paymentPaid;
public Signal $paymentFailed;
public function __construct() {
$this->paymentPaid = new Signal('mollie.payment.paid');
$this->paymentFailed = new Signal('mollie.payment.failed');
}
/**
* @Route("/webhook/mollie")
*/
public function webhook(Request $request): Response {
$payment = $this->mollie->payments->get($request->get('id'));
if ($payment->isPaid()) {
$this->paymentPaid->emit($payment);
} else {
$this->paymentFailed->emit($payment);
}
return new Response('OK');
}
}
Creating a Signal Listener
A signal listener wires slots to signals using @ListenTo annotations. Listeners must extend AbstractProvider
and implement SignalProviderInterface. Each annotated method is automatically connected to the named
signal — no manual wiring code is needed:
namespace App\Listeners;
use Quellabs\Discover\Provider\AbstractProvider;
use Quellabs\Canvas\Routing\Contracts\SignalProviderInterface;
use Quellabs\Canvas\Annotations\ListenTo;
class PaymentListener extends AbstractProvider implements SignalProviderInterface {
public function __construct(
private PaymentLogger $logger,
private InvoiceService $invoiceService
) {}
/**
* @ListenTo("mollie.payment.paid")
*/
public function onPaymentPaid(mixed $payment): void {
$this->logger->onPaymentPaid($payment);
}
/**
* @ListenTo("mollie.payment.failed")
*/
public function onPaymentFailed(mixed $payment): void {
$this->invoiceService->onPaymentFailed($payment);
}
}
Registering the Listener
Declare the listener in composer.json under the extra.discover.signal-hub key. Canvas
discovers all listeners across installed packages at boot time:
{
"extra": {
"discover": {
"signal-hub": {
"providers": [
"App\\Listeners\\PaymentListener"
]
}
}
}
}
A config file can be attached the same way as with service providers. The configuration is then available to the
listener via getConfig():
{
"extra": {
"discover": {
"signal-hub": {
"provider": "App\\Listeners\\PaymentListener",
"config": "config/signals.php"
}
}
}
}
Registering via Directory Convention
For application-level listeners that live in your own codebase, placing them in src/Listeners/ is enough
— no composer.json entry is needed. Canvas scans this directory automatically at boot time.
The default scan path can be overridden in your Canvas configuration if your project layout differs:
// config/app.php
return [
'signal_listeners_path' => __DIR__ . '/../src/Listeners',
];
If neither the default directory nor a configured path exists, the directory scan is silently skipped and only Composer-registered listeners are wired.
Multiple Slots per Signal
Any number of listeners can connect to the same signal. Slots execute in priority order — higher priority runs first.
Priority is specified as an argument to @ListenTo and defaults to 0:
// In PaymentListener — runs at default priority 0
/**
* @ListenTo("mollie.payment.paid")
*/
public function onPaymentPaid(mixed $payment): void {
$this->logger->log($payment);
}
// In AuditListener — also connects to the same signal, runs first
/**
* @ListenTo("mollie.payment.paid", priority=10)
*/
public function onPaymentPaid(mixed $payment): void {
$this->auditTrail->record($payment);
}
Signal Lifecycle
Signals are scoped to the controller's lifetime. Canvas registers them before dispatch and releases them when the request completes — including on exception. No manual cleanup is needed in application code.