Events
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.
// 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:
dart run magic:dowel make:event OrderShipped
This creates lib/app/events/order_shipped.dart with a MagicEvent stub.
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:
'providers': [
(app) => AppEventServiceProvider(app), // Your events and listeners
// ... other providers
],
Create the event directories:
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:
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:
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:
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:
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:
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:
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.
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. |
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 |
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 |
// 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 |