Skip to content

Understanding Modules

Modules are how you organize and register functionality in Verge. Every feature in the framework—routing, caching, logging, events—is implemented as a module. Your application code uses the same pattern.

A module is simply a callable that receives the App instance. That’s it. No interfaces to implement, no base classes to extend:

// A module as a closure
$app->module(function (App $app) {
$app->singleton(UserRepository::class, fn() => new UserRepository());
$app->get('/users', UserController::class);
});
// A module as a class
class UserModule
{
public function __invoke(App $app): void
{
$app->singleton(UserRepository::class, fn() => new UserRepository());
$app->get('/users', UserController::class);
}
}
$app->module(UserModule::class);

Both approaches are equivalent. Use closures for quick inline registration. Use classes when you want reusable, testable modules.

Modules solve several problems at once:

Instead of scattering service bindings, routes, and middleware across your codebase, modules group related functionality together. A PaymentModule contains everything about payments—services, routes, event listeners.

Modules compose naturally. You can build an application by combining modules:

$app->module([
AuthModule::class,
UserModule::class,
PaymentModule::class,
NotificationModule::class,
]);

Each module is self-contained. Add or remove features by adding or removing modules.

There’s no auto-discovery. Your application only includes what you explicitly register. This makes the bootstrap process predictable—you can read the module list and know exactly what’s in your application.

Modules have access to the full App API. They can:

Register services:

$app->singleton(PaymentGateway::class, fn() => new StripeGateway());
$app->bind(PaymentProcessor::class, fn() => new PaymentProcessor());

Define routes:

$app->get('/checkout', CheckoutController::class);
$app->post('/webhook/stripe', StripeWebhookController::class);

Attach middleware:

$app->use(CorsMiddleware::class);

Register event listeners:

$app->on('order.placed', SendConfirmationEmail::class);
$app->on('payment.failed', NotifyAdminOnFailure::class);

Register drivers:

$app->driver('payment', 'stripe', fn() => new StripeGateway());
$app->driver('payment', 'paypal', fn() => new PayPalGateway());
$app->defaultDriver('payment', 'stripe');

Configure other modules:

$app->ready(function() use ($app) {
// Runs after all modules are loaded
$app->get('/admin/routes', fn() => $app->routes());
});

Modules execute in the order you register them. This matters when modules depend on each other:

$app->module([
EnvModule::class, // First: loads environment variables
ConfigModule::class, // Second: uses env to build config
DatabaseModule::class, // Third: uses config for connection
]);

If DatabaseModule runs before ConfigModule, it won’t have access to configuration values. Order your modules so dependencies are registered before dependents.

Verge provides four core primitives: Container, Router, Middleware, and Events. Modules use these primitives to build features.

Before creating a new abstraction, ask yourself:

  • Can I do this with a container binding?
  • Can I do this with middleware?
  • Can I do this with an event listener?
  • Can I compose existing primitives?

Most problems can be solved by combining what’s already there. A module is just the place where that composition happens.

class RateLimitModule
{
public function __invoke(App $app): void
{
// Use container for the limiter service
$app->singleton(RateLimiter::class, fn() => new RateLimiter());
// Use middleware to enforce limits
$app->use(RateLimitMiddleware::class);
// Use events for monitoring
$app->on('rate.exceeded', LogRateLimitExceeded::class);
}
}

Three primitives, one cohesive feature. That’s the Verge way.