Skip to content

Events

View raw .md

Magic's event system provides a simple observer implementation, letting you decouple side effects from application actions by dispatching named events and registering typed listeners.

On this page

Introduction#

Magic provides a simple observer implementation, allowing you to subscribe and listen for various events that occur in your application. Events serve as a great way to decouple various aspects of your application, since a single event can have multiple listeners that do not depend on each other.

dart
// Dispatch an event when an order ships
await Event.dispatch(OrderShipped(order));

// Listeners react to the event (send email, update analytics, etc.)

Generating Events & Listeners#

Use the Magic CLI to scaffold event and listener classes:

bash
dart run magic:dowel make:event OrderShipped

This creates lib/app/events/order_shipped.dart with a MagicEvent stub.

bash
dart run magic:dowel make:listener SendEmail --event=OrderShipped

This creates lib/app/listeners/send_email.dart with a MagicListener<OrderShipped> stub, pre-wired to the specified event.

Configuration#

Enabling Event Support#

Create an event service provider that extends BaseEventServiceProvider (see Registering Events & Listeners) and add it to your providers in config/app.dart:

dart
'providers': [
  (app) => AppEventServiceProvider(app),  // Your events and listeners
  // ... other providers
],

Create the event directories:

text
lib/app/
├── events/
│   └── order_shipped.dart
└── listeners/
    └── send_shipment_notification.dart

Defining Events#

An event class is a simple data container holding information related to the event:

dart
import 'package:magic/magic.dart';
import '../models/order.dart';

class OrderShipped extends MagicEvent {
  final Order order;
  final String trackingNumber;

  OrderShipped(this.order, {required this.trackingNumber});
}

Events are simple data classes with no logic of their own. The listener is responsible for processing the event.

Defining Listeners#

Event listeners receive the event instance in their handle method:

dart
import 'package:magic/magic.dart';
import '../events/order_shipped.dart';

class SendShipmentNotification extends MagicListener<OrderShipped> {
  @override
  Future<void> handle(OrderShipped event) async {
    // Send push notification
    await NotificationService.send(
      to: event.order.user,
      title: 'Order Shipped!',
      body: 'Your order #${event.order.id} is on its way.',
    );
  }
}

class UpdateAnalytics extends MagicListener<OrderShipped> {
  @override
  Future<void> handle(OrderShipped event) async {
    await Analytics.track('order_shipped', {
      'order_id': event.order.id,
      'value': event.order.total,
    });
  }
}

Registering Events & Listeners#

Register mappings in your AppEventServiceProvider:

dart
import 'package:magic/magic.dart';
import '../events/order_shipped.dart';
import '../events/user_registered.dart';
import '../listeners/send_shipment_notification.dart';
import '../listeners/update_analytics.dart';
import '../listeners/send_welcome_email.dart';

class AppEventServiceProvider extends BaseEventServiceProvider {
  AppEventServiceProvider(super.app);

  @override
  Map<Type, List<MagicListener Function()>> get listen => {
    // An event can have multiple listeners
    OrderShipped: [
      () => SendShipmentNotification(),
      () => UpdateAnalytics(),
    ],
    UserRegistered: [
      () => SendWelcomeEmail(),
    ],
  };
}

Dispatching Events#

Use the Event facade to dispatch events:

dart
import 'package:magic/magic.dart';
import '../events/order_shipped.dart';

class OrderController extends MagicController {
  Future<void> shipOrder(Order order) async {
    // Update order status
    order.status = 'shipped';
    await order.save();
    
    // Dispatch event - listeners handle the rest
    await Event.dispatch(OrderShipped(
      order,
      trackingNumber: generateTrackingNumber(),
    ));
    
    Magic.success('Shipped', 'Order has been shipped!');
  }
}

Inline Listeners#

Event.listen<T>(factory) registers a listener without adding a mapping to AppEventServiceProvider.listen. It takes a factory, MagicListener Function(), the same shape listen registers under the hood, not a bare closure:

dart
class LogOrderShipped extends MagicListener<OrderShipped> {
  @override
  Future<void> handle(OrderShipped event) async {
    Log.info('Order shipped: ${event.order.id}');
  }
}

Event.listen<OrderShipped>(() => LogOrderShipped());

T is the registration key and must be named explicitly: leave it off and Dart infers MagicEvent, which no dispatched event matches exactly.

Wildcard Listeners and ReportsBreadcrumb#

Event.listenAny(callback) registers a callback that runs on EVERY dispatched event, regardless of type, after every typed listener has already run (Laravel's Event::listen('*')). It answers a remover:

dart
final remove = Event.listenAny((event) => Log.debug('dispatched: ${event.runtimeType}'));
remove(); // stop listening

ReportsBreadcrumb is a contract an event implements to opt into a crash reporter's breadcrumb trail without the reporter needing to know the event exists: a reporter (the magic_sentry plugin, for one) registers a wildcard listener that checks event is ReportsBreadcrumb on every dispatch and translates a match into a breadcrumb.

dart
class ScreenOpened extends MagicEvent implements ReportsBreadcrumb {
  ScreenOpened(this.path);

  final String path;

  @override
  String get breadcrumbCategory => 'navigation';

  @override
  String get breadcrumbMessage => 'Screen opened';

  @override
  Map<String, Object?> get breadcrumbData => {'path': Uri.parse(path).path};
}

breadcrumbData is a WHITELIST, not a dump of the event: it never carries a URL with a query string, an auth token, or a raw payload value, since a breadcrumb trail is uploaded to a third party. The event author keeps that rule, because a reporter copies the map as it is: the sample sends Uri.parse(path).path so a query such as ?token=... never leaves the device. A wildcard listener's own throw is caught and logged the same way a typed listener's is; it never stops the caller or another wildcard callback.

Framework Events#

Magic fires several system events automatically.

Authentication Events#

Event Fired When
AuthLogin A guard's startSession finishes: the token (when given) is persisted, the user is set and cached. Not fired on a restore.
AuthLogout A guard's logout() ends the in-memory session, guest logout included. Not a promise the credentials are gone: it fires even when a Vault delete failed, so a listener releasing server-side state must gate on Auth.hasToken().
AuthRestored An API-confirmed sync (BaseGuard's background /user fetch) sets the user; not fired for the cache-only step of Auth.restore(). changed is false when the sync only confirmed the user already held.
dart
class LogAuthLogin extends MagicListener<AuthLogin> {
  @override
  Future<void> handle(AuthLogin event) async {
    Log.info('User logged in: ${event.user.authIdentifier}');
  }
}

Event.listen<AuthLogin>(() => LogAuthLogin());

Model Lifecycle Events#

Event Fired When
ModelSaving Before model is saved
ModelSaved After model is saved
ModelCreating Before new model is created
ModelCreated After new model is created
ModelUpdating Before existing model is updated
ModelUpdated After existing model is updated
ModelDeleted After model is deleted
dart
class LogNewUser extends MagicListener<ModelCreated> {
  @override
  Future<void> handle(ModelCreated event) async {
    if (event.model is User) {
      final user = event.model as User;
      Log.info('New user registered: ${user.email}');
    }
  }
}

Event.listen<ModelCreated>(() => LogNewUser());

Gate Events#

Event Fired When
GateAbilityDefined Ability registered with Gate.define()
GateAccessChecked After any ability check
GateAccessDenied When access is denied
dart
// Log denied access attempts
class LogDeniedAccess extends MagicListener<GateAccessDenied> {
  @override
  Future<void> handle(GateAccessDenied event) async {
    Log.warning('Access denied: ${event.ability} for user ${event.user?.id}');
  }
}

Event.listen<GateAccessDenied>(() => LogDeniedAccess());

Database Events#

Core defines DatabaseConnected and QueryExecuted, but fires neither itself: the magic_local_storage plugin, which owns the database layer, is the source of them. See Installation.

Event Fired When
DatabaseConnected Database connection established
QueryExecuted After query execution
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.