Events & Listeners

Svelar provides a Laravel-inspired event system for decoupling your application. Events represent things that happened; listeners react to them.

Creating Events

npx svelar make:event UserRegistered --module=auth

This generates src/lib/modules/auth/domain/events/UserRegistered.ts (or src/lib/events/UserRegistered.ts in flat projects):

export class UserRegistered {
  constructor(
    public readonly user: User,
    public readonly source?: string,
  ) {}
}

Events are plain classes — no base class required. They carry the data that listeners need. A constructor-only event class is valid: TypeScript public constructor properties are the event payload. Local class-based registrations use constructor identity, so unrelated module events with the same class name do not share listeners. Legacy string-name mappings reject ambiguous same-name class dispatch instead of delivering to the wrong module.

Creating Listeners

npx svelar make:listener SendWelcomeEmail --event=UserRegistered --module=auth

This generates src/lib/modules/auth/application/listeners/SendWelcomeEmail.ts (or src/lib/listeners/SendWelcomeEmail.ts in flat projects):

import { Listener } from '@beeblock/svelar/events';
import type { UserRegistered } from '$lib/modules/auth/domain/events/UserRegistered.js';

export class SendWelcomeEmail extends Listener<UserRegistered> {
  async handle(event: UserRegistered): Promise<void> {
    await Mailer.sendMailable(new WelcomeEmail(event.user));
  }

  // Optionally filter which events to handle:
  shouldHandle(event: UserRegistered): boolean {
    return event.source !== 'import'; // Skip imported users
  }
}

Sync vs Queued Work

Event dispatch is synchronous by default: await Event.dispatch(new UserRegistered(user)) waits for every registered listener to finish. Use this for small, important side effects that should complete before the original request finishes, such as cache invalidation, audit bookkeeping, or writing local metadata.

For expensive or retryable work, keep the listener small and dispatch a job from inside the listener:

import { Listener } from '@beeblock/svelar/events';
import { Queue } from '@beeblock/svelar/queue';
import { SendWelcomeEmailJob } from '$lib/shared/jobs/SendWelcomeEmailJob.js';
import type { UserRegistered } from '$lib/modules/auth/domain/events/UserRegistered.js';

export class SendWelcomeEmail extends Listener<UserRegistered> {
  async handle(event: UserRegistered): Promise<void> {
    await Queue.dispatch(
      new SendWelcomeEmailJob({ userId: event.user.id }),
      { queue: 'mail' }
    );
  }
}

This gives you the same module-decoupling benefits while letting the queue system handle retries, backoff, worker isolation, and slow external services.

Production Class Names

Queued job registries and legacy event-provider mappings use constructor names as identifiers. Svelar's package build preserves these names across independently bundled subpaths, including PDF jobs. If you customize application bundling, preserve class names too (keepNames: true with esbuild); do not rename serialized job identifiers between web and worker builds while jobs are pending. Local class-based listeners and Model observers use constructor identity. String-named events use the explicit name passed to Event.emit().

Context And Transport

Dispatch captures the originating tenant, actor and correlation context. Each listener runs in an isolated copy, including wildcard listeners: changing context inside one listener does not change the next. Pass { context: {} } explicitly for a system event that must not inherit request identity.

For serialized jobs, use an explicitly namespaced transport name and JSON payload instead of serializing class instances:

const envelope = Event.capture('billing.invoice.created', { invoiceId });
await Queue.dispatch(new DeliverInvoiceEventJob({ envelope }));
// In the registered job's handle():
await Event.replay(this.data.envelope);

Register a named listener with Event.listen('billing.invoice.created', ...) in the worker bootstrap. Replay restores the captured context, not the operator/worker ambient identity. Envelope validation rejects non-JSON values, cycles, accessors and oversized data (1 MiB, depth 32, 10,000 nodes). capture does not dispatch, persist or enqueue anything by itself. Deliver jobs after commit, or persist an application outbox when delivery must be durable.

Dispatching Events

import { Event } from '@beeblock/svelar/events';

// Class-based event
await Event.dispatch(new UserRegistered(user));

// String-based event
await Event.emit('order.shipped', { orderId: 123, trackingNumber: 'ABC' });

Inline Listeners

For quick one-offs, register listeners directly:

import { Event } from '@beeblock/svelar/events';

// String-based
Event.listen('user.created', async (user) => {
  console.log('New user:', user.getAttribute('email'));
});

// Class-based (typed)
Event.listen(UserRegistered, async (event) => {
  await analytics.track('signup', { email: event.user.email });
});

// One-time listener
Event.once('app.booted', async () => {
  console.log('Application started');
});

// Wildcard listener (fires for every event)
Event.onAny(async (eventName, payload) => {
  console.log(`[Event] ${eventName}`, payload);
});

EventServiceProvider

For production apps, register all event mappings and observers in an EventServiceProvider. This gives you a single place to see all event wiring.

npx svelar make:provider EventServiceProvider

Then extend the built-in EventServiceProvider base:

// src/lib/shared/providers/EventServiceProvider.ts
import { EventServiceProvider as BaseProvider } from '@beeblock/svelar/events';
import { UserRegistered } from '$lib/modules/auth/domain/events/UserRegistered.js';
import { OrderPlaced } from '$lib/modules/orders/domain/events/OrderPlaced.js';
import { SendWelcomeEmail } from '$lib/modules/auth/application/listeners/SendWelcomeEmail.js';
import { CreateUserProfile } from '$lib/modules/auth/application/listeners/CreateUserProfile.js';
import { NotifyWarehouse } from '$lib/modules/orders/application/listeners/NotifyWarehouse.js';
import { User } from '$lib/modules/auth/domain/models/User.js';
import { UserObserver } from '$lib/modules/auth/domain/observers/UserObserver.js';
import { AuditObserver } from '$lib/modules/auth/domain/observers/AuditObserver.js';

export class EventServiceProvider extends BaseProvider {
  // Map events to their listeners
  protected listen = {
    // Class-based events (use .name for the key)
    [UserRegistered.name]: [SendWelcomeEmail, CreateUserProfile],
    [OrderPlaced.name]: [NotifyWarehouse],

    // String-based events (model lifecycle, custom, etc.)
    'user.updated': [
      async (user: any) => { await invalidateUserCache(user); },
    ],
  };

  // Map models to their observers
  protected observers = {
    [User.name]: [UserObserver, AuditObserver],
  };
}

Register the provider and models in your app bootstrap:

// src/app.ts
import { Application } from '@beeblock/svelar/container';
import { EventServiceProvider as BaseProvider } from '@beeblock/svelar/events';
import { EventServiceProvider } from '$lib/shared/providers/EventServiceProvider.js';
import { User } from '$lib/modules/users/domain/models/User.js';
import { Post } from '$lib/modules/posts/domain/models/Post.js';

// Register models so observers can be attached by name
BaseProvider.registerModels(User, Post);

const app = new Application();
app.register(EventServiceProvider);
await app.bootstrap();

Subscribers

Subscribers let a single class listen to multiple events:

import { type EventDispatcher, type Subscriber } from '@beeblock/svelar/events';

export class UserEventSubscriber implements Subscriber {
  subscribe(events: EventDispatcher): void {
    events.listen('user.created', this.onCreated.bind(this));
    events.listen('user.deleted', this.onDeleted.bind(this));
    events.listen('user.updated', this.onUpdated.bind(this));
  }

  async onCreated(user: any) { /* ... */ }
  async onDeleted(user: any) { /* ... */ }
  async onUpdated(user: any) { /* ... */ }
}

// Register directly
Event.subscribe(new UserEventSubscriber());

// Or via EventServiceProvider:
protected subscribe = [UserEventSubscriber];

Model Lifecycle Events

Every model automatically dispatches events through the Event system. See Model Observers for the full reference.

// These fire automatically — no setup needed
Event.listen('user.creating', async (user) => { /* before insert */ });
Event.listen('user.created', async (user) => { /* after insert */ });
Event.listen('user.updating', async (user) => { /* before update */ });
Event.listen('user.updated', async (user) => { /* after update */ });
Event.listen('user.deleting', async (user) => { /* before delete */ });
Event.listen('user.deleted', async (user) => { /* after delete */ });

Removing Listeners

// The listen() method returns an unsubscribe function
const unsubscribe = Event.listen('user.created', handler);
unsubscribe(); // Remove this specific listener

// Remove all listeners for an event
Event.forget('user.created');
Event.forget(UserRegistered); // Class-based

// Remove everything
Event.flush();

// Check if listeners exist
Event.hasListeners('user.created');     // boolean
Event.listenerCount(UserRegistered);    // number
Svelar © 2026 · MIT License