Request Lifecycle
Understanding the Magic request lifecycle helps you know exactly where your service providers, middleware, and controllers run relative to each bootstrap step.
On this page
Introduction#
Understanding the Magic request lifecycle will help you build better applications. This document covers how a Magic application starts up, handles navigation requests, and renders views.
Lifecycle Overview#
┌─────────────────────────────────────────────────────────┐
│ main.dart │
│ │ │
│ Magic.init() │
│ │ │
│ Env.load() │
│ │ │
│ configFactories evaluated │
│ │ │
│ URL strategy applied (web) │
│ │ │
│ ┌────────────────┴────────────────┐ │
│ ▼ ▼ │
│ providers register() (sync) (all providers) │
│ │ │
│ providers boot() (async, all registered) │
│ │ │
│ router pre-build │
│ │ │
│ runApp(MagicApplication) │
│ │ │
│ Route Matched (GoRouter) │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ Run Middleware Resolve Layout Get Controller │
│ │
│ │ │
│ Controller.method() │
│ │ │
│ Render View │
└─────────────────────────────────────────────────────────┘
Application Bootstrap#
The application starts in main.dart:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. Initialize Magic
await Magic.init(
envFileName: '.env',
configFactories: [
() => appConfig,
() => networkConfig,
() => authConfig,
],
);
// 2. Restore authentication (optional)
await Auth.restore();
// 3. Run migrations (development)
if (kDebugMode) {
await Migrator().run([...migrations]);
}
// 4. Start the application
runApp(MagicApplication(
title: 'My App',
initialRoute: Auth.check() ? '/dashboard' : '/login',
));
}
Magic.init() Steps#
- Load Environment - Reads
.envfile into memory viaEnv.load() - Evaluate Config Factories - Runs all
configFactoriesnow thatEnvis populated - Apply URL Strategy - Configures path vs hash URL strategy for web (before any widget tree exists)
- Register Service Providers - Calls
register()on each provider synchronously - Boot Service Providers - Calls
boot()on each provider asynchronously - Pre-build Router - Resolves
MagicRouter.instance.routerConfigso GoRouter is ready beforerunApp
Service Providers#
Providers are registered in config/app.dart:
'providers': [
(app) => NetworkServiceProvider(app),
(app) => AuthServiceProvider(app),
(app) => DatabaseServiceProvider(app),
(app) => CacheServiceProvider(app),
(app) => LocalizationServiceProvider(app),
(app) => EventServiceProvider(app),
(app) => AppServiceProvider(app),
],
Provider Lifecycle#
- register() - Bind services to container (no dependencies)
- boot() - Initialize services (can use other services)
class AppServiceProvider extends ServiceProvider {
@override
void register() {
// Runs first - just bind services
app.bind('api', () => ApiService());
}
@override
Future<void> boot() async {
// Runs after ALL providers register
// Safe to use other services here
Gate.before((user, ability) {
if ((user as User).isAdmin) return true;
return null;
});
}
}
Registering a provider after boot#
A provider registered once the app has already booted is booted on the spot,
rather than waiting for a boot phase that has been and gone. register()
returns a Future so a caller that needs the provider wired before its next
line can wait for it:
// A plugin installing itself lazily, after the app is running.
await Magic.app.register(NotificationsServiceProvider(Magic.app));
Notify.startPolling(); // the provider has finished booting
Ignoring the future is fine when you do not need that guarantee. Before the boot phase it completes immediately, since nothing asynchronous has happened yet.
Registering the same provider instance twice is a no-op the second time, so
its register() and boot() each run once. Two different instances of the same
provider class both run, which is deliberate: a provider parameterised per
plugin is a legitimate shape.
Routing#
Routes are defined in lib/routes/:
// lib/routes/web.dart
void registerRoutes() {
// Guest routes
MagicRoute.group(
layoutId: 'guest',
layout: (child) => GuestLayout(child: child),
routes: () {
MagicRoute.page('/login', () => AuthController.instance.login());
MagicRoute.page('/register', () => AuthController.instance.register());
},
);
// Authenticated routes
MagicRoute.group(
layoutId: 'app',
middleware: ['auth'],
layout: (child) => AppLayout(child: child),
routes: () {
MagicRoute.page('/dashboard', () => DashboardView());
MagicRoute.page('/settings', () => SettingsView());
},
);
}
Each group with a layout: is its own go_router ShellRoute, so the guest and authenticated groups are separate shells. Groups that share a layoutId share one persistent shell, and shells are emitted in registration order. See Layouts.
Route Resolution#
- URL is matched against defined routes
- Layout is wrapped around the view
- Middleware is executed in order
- Controller/View is resolved
Middleware#
Middleware intercepts navigation before the view renders:
class EnsureAuthenticated extends MagicMiddleware {
@override
Future<void> handle(void Function() next) async {
if (Auth.check()) {
next(); // Allow navigation
} else {
MagicRoute.to('/login'); // Redirect
}
}
}
Middleware Execution Order#
- Global middleware (registered in provider)
- Route group middleware
- Route-specific middleware
Controller Dispatch#
Controllers are resolved using the findOrPut pattern:
// Route definition
MagicRoute.page('/tasks', () => TaskController.instance.index());
// Controller
class TaskController extends MagicController {
static TaskController get instance => Magic.findOrPut(TaskController.new);
Widget index() {
if (isEmpty) _loadTasks();
return const TaskListView();
}
}
Controller Lifecycle#
- findOrPut - Get existing or create new controller
- onInit() - Called when controller is created
- Method execution - Returns view widget
- State updates -
notifyListeners()triggers rebuilds - onClose() - Called when controller is disposed
View Rendering#
Views render using controller state:
class TaskListView extends MagicView {
const TaskListView({super.key});
@override
Widget build(BuildContext context) {
final controller = TaskController.instance;
return controller.renderState(
(tasks) => _buildList(tasks),
onLoading: CircularProgressIndicator(),
onError: (msg) => ErrorWidget(message: msg),
onEmpty: EmptyState(),
);
}
}
State Flow#
- Controller calls
setLoading()→ View shows loading - Controller calls
setSuccess(data)→ View renders data - Controller calls
setError(msg)→ View shows error - Controller calls
notifyListeners()→ View rebuilds