Features

Events & Listeners

Decouple features with event-driven architecture

Build loosely coupled systems by emitting events and registering listeners to react.

Quick Start

1. Define an Event

namespace App\Events;

use Glueful\Events\Contracts\BaseEvent;

class UserRegisteredEvent extends BaseEvent
{
    public function __construct(
        public readonly string $userUuid,
        public readonly string $email
    ) {
        parent::__construct();
        $this->setMetadata('source', 'registration');
    }
}

2. Listen to Event

Register listeners at boot, typically in a service provider's boot():

use App\Events\UserRegisteredEvent;
use Glueful\Bootstrap\ApplicationContext;
use Glueful\Events\EventService;
use Glueful\Queue\QueueManager;

public function boot(ApplicationContext $context): void
{
    $events = app($context, EventService::class);

    $events->addListener(UserRegisteredEvent::class, function (UserRegisteredEvent $event) use ($context) {
        // Send the welcome email from a queued job
        app($context, QueueManager::class)
            ->push(SendWelcomeEmailJob::class, ['user_uuid' => $event->userUuid]);
    });
}

3. Dispatch Event

use Glueful\Events\EventService;

app($context, EventService::class)->dispatch(new UserRegisteredEvent($user['uuid'], $user['email']));

When to Use Events

Use events when:

  • Multiple subsystems react to same action
  • You want loose coupling
  • Side effects are optional
  • Fan-out to multiple handlers

Use direct calls when:

  • Only one consumer exists
  • Coupling is acceptable
  • Action must be synchronous

Basic Usage

Dispatch Events

use Glueful\Events\EventService;

$events = app($context, EventService::class);

// Dispatch with data
$events->dispatch(new OrderPlacedEvent($orderId, $total));

// Multiple listeners will execute

Register Listeners

$events = app($context, EventService::class);

// Closure listener
$events->addListener(OrderPlacedEvent::class, function($event) use ($context) {
    app($context, \Psr\Log\LoggerInterface::class)->info('Order placed', ['id' => $event->orderId]);
});

// Lazy listener via container service reference (id or id:method)
$events->addListener(OrderPlacedEvent::class, '@send_order_confirmation:handle');

// Priority: higher runs first (default 0); equal priorities run in registration order
$events->addListener(OrderPlacedEvent::class, $auditListener, priority: 100);

Registering the same listener twice for an event is de-duplicated. $events->hasListeners(OrderPlacedEvent::class) and getListeners() let you inspect what's registered.

Listener Failures

dispatch() is fault-isolated: if a listener throws, the exception is logged (error_log) and the remaining listeners still run. The caller never sees the exception.

When a failure must surface (for example, a webhook handler that should be redelivered on error), use dispatchOrFail(). It rethrows the first listener's original exception and stops dispatching, so listeners after it don't run. A caller may retry the same event, so listeners on strictly-dispatched events must be idempotent.

try {
    $events->dispatchOrFail(new PaymentCapturedEvent($paymentId));
} catch (\Throwable $e) {
    // mark for redelivery
}

Event Subscribers

Group related listeners in a subscriber:

namespace App\Listeners;

use Glueful\Events\EventSubscriberInterface;

class UserLifecycleSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            UserRegisteredEvent::class => 'onRegistered',
            UserUpdatedEvent::class => ['onUpdated', 10], // [method, priority]
            UserDeletedEvent::class => 'onDeleted',
        ];
    }

    public function onRegistered(UserRegisteredEvent $event): void
    {
        // Send welcome email
    }

    public function onUpdated(UserUpdatedEvent $event): void
    {
        // Clear cache
    }

    public function onDeleted(UserDeletedEvent $event): void
    {
        // Cleanup data
    }
}

Register the subscriber with the event service:

app($context, EventService::class)->subscribe(UserLifecycleSubscriber::class);

How subscribe() builds the class depends on whether it's a container service:

  • Registered service (listed in a provider's services()): each method is registered as a lazy @Class:method container reference, resolved the first time one of its events is dispatched.
  • Not registered: subscribe() constructs the class right away, autowiring its constructor dependencies from the container, and registers the instance. If it can't be built (an unresolvable dependency, for example), subscribe() throws InvalidArgumentException and registers none of the class's listeners.

Either way the class can take constructor dependencies. Register it as a service when you want it built lazily, or shared with other code:

public static function services(): array
{
    return [
        UserLifecycleSubscriber::class => ['class' => UserLifecycleSubscriber::class, 'autowire' => true],
    ];
}

Attribute Listeners

#[AsListener] (Glueful\Events\Attributes\AsListener) declares listeners on the class itself, without implementing EventSubscriberInterface. It is read by the same subscribe() call; there is no directory scanning, so every class must still be subscribed.

On methods

Put the attribute on any public method; it is repeatable, so one method can handle several events:

namespace App\Listeners;

use Glueful\Events\Attributes\AsListener;

final class OrderListeners
{
    #[AsListener(OrderPlacedEvent::class, priority: 10)]
    public function reserveStock(OrderPlacedEvent $event): void { /* ... */ }

    #[AsListener(OrderPlacedEvent::class)]
    #[AsListener(OrderUpdatedEvent::class)]
    public function refreshSearchIndex(object $event): void { /* ... */ }
}

On the class

On the class, method names the handler; without it, __invoke is called:

#[AsListener(UserRegisteredEvent::class)]                            // calls __invoke()
#[AsListener(UserDeletedEvent::class, method: 'onDeleted', priority: -10)]
final class UserAuditListener
{
    public function __invoke(UserRegisteredEvent $event): void { /* ... */ }

    public function onDeleted(UserDeletedEvent $event): void { /* ... */ }
}

Registering

// Optional: list the classes in services() to build them lazily on first dispatch

public function boot(ApplicationContext $context): void
{
    $events = app($context, EventService::class);
    $events->subscribe(OrderListeners::class);
    $events->subscribe(UserAuditListener::class);
}
ArgumentMeaning
eventEvent class (listeners on a parent class or interface also receive subclasses)
priorityHigher runs first; default 0, same ordering as addListener()
methodClass-level only: the public method to call (default __invoke)

A class may combine EventSubscriberInterface and #[AsListener]; both sets are registered. subscribe() throws InvalidArgumentException when the class doesn't exist, has neither getSubscribedEvents() nor any #[AsListener], a class-level attribute names a method that isn't public or doesn't exist, or an unregistered class can't be constructed.

Async Event Handling

Queue expensive listeners:

$events = app($context, EventService::class);

$events->addListener(UserRegisteredEvent::class, function($event) use ($context) {
    // Listeners always run synchronously, inside dispatch()
    app($context, \Psr\Log\LoggerInterface::class)->info('User registered');
});

$events->addListener(UserRegisteredEvent::class, function($event) use ($context) {
    // Push expensive work to a queued job
    $queue = app($context, \Glueful\Queue\QueueManager::class);
    $queue->push(ProcessNewUserJob::class, ['user_uuid' => $event->userUuid]);
});

Common Patterns

User Registration

// Event
class UserRegisteredEvent extends BaseEvent
{
    public function __construct(
        public readonly string $userUuid,
        public readonly string $email,
        public readonly string $name
    ) {
        parent::__construct();
    }
}

// Listeners
$events = app($context, EventService::class);

$events->addListener(UserRegisteredEvent::class, function($event) use ($context) {
    // Send welcome email
    $queue = app($context, \Glueful\Queue\QueueManager::class);
    $queue->push(SendWelcomeEmailJob::class, ['user_uuid' => $event->userUuid]);
});

$events->addListener(UserRegisteredEvent::class, function($event) use ($context) {
    // Create default preferences
    db($context)->table('user_preferences')->insert([
        'user_uuid' => $event->userUuid,
        'notifications' => true
    ]);
});

// Dispatch
$events->dispatch(new UserRegisteredEvent(
    $user['uuid'],
    $user['email'],
    $user['name']
));

Order Processing

class OrderPlacedEvent extends BaseEvent
{
    public function __construct(
        public readonly int $orderId,
        public readonly int $userId,
        public readonly float $total
    ) {
        parent::__construct();
    }
}

$events = app($context, EventService::class);

// Process payment
$events->addListener(OrderPlacedEvent::class, function($event) use ($context) {
    app($context, \Glueful\Queue\QueueManager::class)
        ->push(ProcessPaymentJob::class, ['order_id' => $event->orderId]);
});

// Send confirmation
$events->addListener(OrderPlacedEvent::class, function($event) use ($context) {
    app($context, \Glueful\Queue\QueueManager::class)
        ->push(SendOrderConfirmationJob::class, ['order_id' => $event->orderId]);
});

// Update inventory
$events->addListener(OrderPlacedEvent::class, function($event) use ($context) {
    app($context, \Glueful\Queue\QueueManager::class)
        ->push(UpdateInventoryJob::class, ['order_id' => $event->orderId]);
});

Cache Invalidation

class PostUpdatedEvent extends BaseEvent
{
    public function __construct(
        public readonly string $postUuid
    ) {
        parent::__construct();
    }
}

app($context, EventService::class)->addListener(PostUpdatedEvent::class, function($event) use ($context) {
    // Clear post cache
    $cache = app($context, \Glueful\Cache\CacheStore::class);
    $cache->delete('post:' . $event->postUuid);

    // Clear list caches
    $cache->deletePattern('posts:*');
});

Built-in Framework Events

Glueful dispatches these events itself. They expose getters, not public properties (a few security/HTTP-auth events use public readonly properties, noted below).

EventDispatched whenUseful accessors
Glueful\Events\Auth\SessionCreatedEventA login session is createdgetUserUuid(), getUsername(), getSessionData()
Glueful\Events\Auth\SessionDestroyedEventA session is destroyedgetUserUuid(), getReason(), isExpired(), isRevoked()
Glueful\Events\Auth\AuthenticationFailedEventUsername/password login failsgetUsername(), getReason(), getClientIp(), isSuspicious()
Glueful\Events\Http\HttpAuthSuccessEvent / HttpAuthFailureEventThe auth middleware accepts / rejects a requestpublic $request; $tokenMetadata / $reason
Glueful\Events\Http\ExceptionEventThe exception handler handles an exceptiongetException(), getRequest(), getExceptionClass()
Glueful\Events\Database\QueryExecutedEventA query runs, whether or not debug mode is on (built only when a listener exists)getSql(), getBindings(), getExecutionTime() (seconds), isSlow(); getMetadata('purpose') carries the withPurpose() label
Glueful\Events\Database\EntityCreatedEvent / EntityUpdatedEvent / EntityDeletedEventBaseRepository writes (and API key changes)getTable(), getEntityId(), getEntity(); getChanges() on updates
Glueful\Events\Security\CSRFViolationEventCSRF origin or token check failspublic $reason, $request
Glueful\Events\Webhook\WebhookDeliveredEvent / WebhookFailedEventAn outbound webhook is delivered / failsgetUrl(), getStatusCode(), getDurationMs(); getReason() on failure
Glueful\Events\Http\RequestEventThe HTTP kernel receives a request, before routinggetRequest(), getMethod(), getUri(), getClientIp()
Glueful\Events\Http\ResponseEventThe final response is built (after CORS and security headers, error responses included)getRequest(), getResponse(), getStatusCode(), isServerError()
Glueful\Events\Cache\CacheHitEvent / CacheMissEventThe container cache store's get() / remember() hits / missesgetKey(); getRetrievalTime() (ms), getValueSize() on hits
Glueful\Events\Auth\RateLimitExceededEventThe rate_limit middleware returns 429getClientIp(), getLimit(), isSevereViolation()
use Glueful\Events\Auth\AuthenticationFailedEvent;
use Glueful\Events\Auth\SessionCreatedEvent;
use Glueful\Events\Database\QueryExecutedEvent;
use Psr\Log\LoggerInterface;

$events = app($context, EventService::class);
$logger = app($context, LoggerInterface::class);

$events->addListener(SessionCreatedEvent::class, function (SessionCreatedEvent $event) use ($logger) {
    $logger->info('User logged in', ['user_uuid' => $event->getUserUuid()]);
});

$events->addListener(AuthenticationFailedEvent::class, function (AuthenticationFailedEvent $event) use ($logger) {
    $logger->warning('Login failed', [
        'username' => $event->getUsername(),
        'reason' => $event->getReason(),
    ]);
});

$events->addListener(QueryExecutedEvent::class, function (QueryExecutedEvent $event) use ($logger) {
    if ($event->isSlow(0.1)) { // seconds
        $logger->warning('Slow query', [
            'sql' => $event->getSql(),
            'seconds' => $event->getExecutionTime(),
        ]);
    }
});

The request, response and cache events are built and dispatched only when at least one listener is registered for them, so they cost a listener lookup when unobserved. A failing listener never breaks the request or the cache read (HTTP event failures are logged as a warning). They are dispatched since 1.91; earlier versions defined the classes but never fired them. See Caching and Rate Limiting for those events.

Configuration

config/events.php:

return [
    'enabled' => env('EVENTS_ENABLED', true),

    // On/off flags for core listeners, not a map of event classes to listeners
    'listeners' => [
        'cache_invalidation' => env('EVENTS_CACHE_INVALIDATION', true),
        'security_monitoring' => env('EVENTS_SECURITY_MONITORING', true),
        'performance_monitoring' => env('EVENTS_PERFORMANCE_MONITORING', true),
        'audit_logging' => env('EVENTS_AUDIT_LOGGING', true),
    ],

    'debug' => env('EVENTS_DEBUG', env('APP_DEBUG', false)),
    'performance' => [
        'enabled' => env('EVENTS_PERFORMANCE_TRACKING', true),
        'slow_threshold_ms' => env('EVENTS_SLOW_THRESHOLD', 100),
        'memory_threshold_mb' => env('EVENTS_MEMORY_THRESHOLD', 10),
    ],
    'extensions' => [
        'enabled' => env('EVENTS_EXTENSIONS_ENABLED', true),
        'auto_register' => env('EVENTS_EXTENSIONS_AUTO_REGISTER', true),
    ],
];

Only enabled and listeners.audit_logging are read. Together they decide whether the core ActivityLoggingSubscriber (auth/security activity logging) is subscribed at boot, and enabled (with api.webhooks.enabled) decides whether the webhook bridge for #[Webhookable] / DispatchesWebhooks events is registered (see Webhooks). enabled = false does not stop dispatch() or your own listeners. The other keys are currently unused. Register your own listeners in code with addListener() or subscribe(); there is no config-file listener map.

Performance Tips

Keep Listeners Fast

$events = app($context, EventService::class);

// ✅ Good - quick logging
$events->addListener(OrderPlacedEvent::class, function($event) use ($context) {
    app($context, \Psr\Log\LoggerInterface::class)->info('Order placed', ['id' => $event->orderId]);
});

// ❌ Bad - slow API call
$events->addListener(OrderPlacedEvent::class, function($event) {
    // This blocks all other listeners!
    $this->externalApi->notify($event->orderId);
});

// ✅ Good - queue slow work
$events->addListener(OrderPlacedEvent::class, function($event) use ($context) {
    app($context, \Glueful\Queue\QueueManager::class)
        ->push(NotifyExternalApiJob::class, ['order_id' => $event->orderId]);
});

Minimal Payloads

// ✅ Good - small payload
class UserUpdatedEvent extends BaseEvent
{
    public function __construct(
        public readonly int $userId
    ) {
        parent::__construct();
    }
}

// ❌ Bad - large payload
class UserUpdatedEvent extends BaseEvent
{
    public function __construct(
        public readonly User $user, // Full object!
        public readonly array $oldData,
        public readonly array $newData
    ) {
        parent::__construct();
    }
}

Stop Propagation

Prevent further listeners from executing:

app($context, EventService::class)->addListener(OrderPlacedEvent::class, function($event) {
    if ($event->total > 10000) {
        // Manual review required
        $event->stopPropagation();
        return;
    }
});

Testing Events

Register a listener, run the code under test, and assert on what it captured:

use Glueful\Events\EventService;

public function testRegistrationDispatchesEvent(): void
{
    $captured = null;

    app($this->context, EventService::class)->addListener(
        UserRegisteredEvent::class,
        function (UserRegisteredEvent $event) use (&$captured) {
            $captured = $event;
        }
    );

    app($this->context, RegistrationService::class)->register('[email protected]', 'secret');

    $this->assertNotNull($captured);
    $this->assertSame('[email protected]', $captured->email);
}

Because dispatch() swallows listener exceptions, assertions inside a listener don't fail the test; capture the event and assert afterwards, as above.

Best Practices

Use Events for Side Effects

// Main action
public function createPost(array $data): string
{
    $this->db->table('posts')->insert($data);

    // Dispatch event for side effects
    $this->events->dispatch(new PostCreatedEvent($data['uuid']));

    return $data['uuid'];
}

// Side effects as listeners
$events = app($context, EventService::class);

$events->addListener(PostCreatedEvent::class, function($event) use ($context) {
    app($context, \Glueful\Cache\CacheStore::class)->delete('posts:latest');
});

$events->addListener(PostCreatedEvent::class, function($event) use ($context) {
    app($context, \Glueful\Queue\QueueManager::class)
        ->push(NotifySubscribersJob::class, ['post_uuid' => $event->postUuid]);
});

Domain Events

// Good domain events
UserRegisteredEvent
OrderPlacedEvent
PaymentProcessedEvent
InvoiceGeneratedEvent

// Not events (these are commands)
RegisterUserEvent  // Use: RegisterUserCommand
PlaceOrderEvent    // Use: PlaceOrderCommand

Troubleshooting

Listeners not firing?

  • Verify the listener is registered before the event is dispatched (e.g. in a provider's boot())
  • Check the event class name matches (listeners on a parent class or interface also receive subclasses)
  • Check the event is one core actually dispatches (see Built-in Framework Events)
  • Subscribed a class (subscribe() / #[AsListener]) that isn't registered in the container? subscribe() builds it straight away and throws InvalidArgumentException if it can't (an unresolvable constructor dependency, say), so check the boot error
  • Look in the PHP error log: a listener that throws is logged and skipped

Performance issues?

  • Profile slow listeners
  • Queue expensive operations
  • Reduce payload size

Events firing multiple times?

  • Check for duplicate listener registrations
  • Verify event is only dispatched once

Next Steps