Request Lifecycle
Understanding the Magic request lifecycle helps you know exactly where your service providers, middleware, and controllers run relative to each bootstrap step.
- Introduction
- Lifecycle Overview
- Application Bootstrap
- Service Providers
- Routing
- Middleware
- Controller Dispatch
- View Rendering
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 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(
layout: (child) => GuestLayout(child: child),
routes: () {
MagicRoute.page('/login', () => AuthController.instance.login());
MagicRoute.page('/register', () => AuthController.instance.register());
},
);
// Authenticated routes
MagicRoute.group(
middleware: ['auth'],
layout: (child) => AppLayout(child: child),
routes: () {
MagicRoute.page('/dashboard', () => DashboardView());
MagicRoute.page('/settings', () => SettingsView());
},
);
}
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 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
[!TIP] Understanding this lifecycle helps you place logic in the right location: configuration in providers, authorization in middleware, business logic in controllers, and UI in views.