Skip to content

Routes

Route files are discovered from each module's Routes/ directory and loaded with middleware groups based on the filename. A file named web.php gets the web middleware group. A file named api.php gets the api prefix and middleware. This convention-based approach means most modules need zero route configuration.

Default configuration

'routes' => [
    'active' => true,
    'patterns' => [
        '*/Routes/*.php',
    ],
    'commands_filenames' => [
        'console',
    ],
    'channels_filenames' => [
        'channels',
    ],
],

Directory layout

Modules/Blog/
└── Routes/
    ├── web.php         // web middleware group
    ├── api.php         // api prefix + middleware group
    ├── admin.php       // custom group (see below)
    ├── channels.php    // broadcast channel definitions
    └── console.php     // Artisan console commands and scheduling

Route files are standard Laravel route files. Write them exactly as you would in routes/web.php or routes/api.php:

// Modules/Blog/Routes/web.php
use Illuminate\Support\Facades\Route;

Route::get('/blog', [PostController::class, 'index']);
Route::get('/blog/{post}', [PostController::class, 'show']);
// Modules/Blog/Routes/api.php
use Illuminate\Support\Facades\Route;

Route::get('/posts', [PostApiController::class, 'index']);
Route::post('/posts', [PostApiController::class, 'store']);

The api.php routes get the /api prefix automatically, so the endpoints above become /api/posts and /api/posts.

Built-in route groups

Two groups come pre-defined:

Filename Group attributes
web.php middleware: ['web']
api.php prefix: 'api', middleware: ['api']

These are the route file types that get discovered with sensible defaults out of the box.

Every route file is discovered

Any .php file you drop into a module's Routes/ directory gets discovered and loaded, even if the filename doesn't match a defined group. You don't have to register it anywhere. Create Routes/admin.php, Routes/webhooks.php, or Routes/anything.php and the routes inside will be loaded automatically.

The catch: a file with a name that doesn't match a defined group gets loaded without any middleware, prefix, or naming attributes. The routes work, but nothing wraps them. If you want middleware, a prefix, a name prefix, or any other group-level configuration applied to that file, define a matching route group (see the next section).

Adding new route file types

If you want the package to discover a new type of route file across modules, like admin.php or settings.php, define a route group with that filename as the key. Every module's matching file will then be loaded with the attributes you configure.

Call this from a service provider's register() method so the group is defined before route discovery runs:

use Mozex\Modules\Facades\Modules;

// In a service provider's register() method
Modules::routeGroup('admin',
    prefix: 'admin',
    middleware: ['web', 'auth', 'is-admin'],
    as: 'admin.',
);

Now any module that creates a Routes/admin.php file gets those attributes applied. The group name must match the filename exactly.

Group attributes accept closures, which are evaluated at registration time:

Modules::routeGroup('api',
    prefix: fn () => config('app.api_prefix', 'api'),
    middleware: ['api', 'throttle:api'],
);

This lets you pull values from config or other sources that aren't available at service provider registration time.

Custom registrars

A routeGroup() call wraps your routes in a standard Route::group($attributes, $routes) call. That works for most cases, but some route types need more than that. For example, a localized.php group might need to be wrapped in a Route::localized() call so every route inside gets locale-aware URLs. Custom registrars let you take over the registration entirely and wrap the routes in whatever logic you need.

use Mozex\Modules\Facades\Modules;
use Illuminate\Support\Facades\Route;

Modules::registerRoutesUsing('localized', function (array $attributes, $routes) {
    Route::localized(function () use ($attributes, $routes): void {
        Route::group($attributes, $routes);
    });
});

Modules::routeGroup('localized', middleware: ['web'], as: 'localized.');

With this setup, every module's Routes/localized.php file is loaded inside a Route::localized() call. The registrar key matches the route filename.

Call both routeGroup() and registerRoutesUsing() from your service provider's register() method.

Inline alternative

A custom registrar wraps every route in a file with the same logic. When you only need the wrapping for some routes, it's often simpler to call the helper directly inside a regular route file:

// Modules/Landing/Routes/web.php
use Illuminate\Support\Facades\Route;
use Modules\Landing\Livewire\BecomeASeller;

Route::get('/terms', [TermsController::class, 'show'])->name('terms');

Route::localized(function () {
    Route::get('become-a-seller', BecomeASeller::class)->name('landing.become-a-seller');
});

Here /terms is a plain route and become-a-seller is wrapped in Route::localized(). No custom registrar or extra file needed. Reach for the custom registrar approach when every route in a dedicated file should share the same wrapping logic.

Organizing by concern

Since every .php file inside Routes/ is discovered, you can split routes by what they relate to rather than by HTTP concerns. A User module that integrates Jetstream and Fortify might have:

Modules/User/
└── Routes/
    ├── fortify.php    // Fortify login, registration, password reset routes
    ├── jetstream.php  // Jetstream team management routes
    └── web.php        // the module's own public routes

Each file defines its own Route::group() internally with whatever middleware the third-party package needs:

// Modules/User/Routes/fortify.php
Route::group([
    'middleware' => config('fortify.middleware', ['web']),
    'prefix' => 'dashboard',
], function () {
    Route::livewire('/login', Login::class)
        ->middleware(['guest:'.config('fortify.guard')])
        ->name('login');
    // ...
});

The filenames fortify.php and jetstream.php don't match any defined route group, so the package loads each file without wrapping. The Route::group() call inside each file does the real work. This keeps routes organized by the third-party concern they belong to without requiring you to register a route group on the facade level.

Console routes

Files listed in the commands_filenames config (default: ['console']) are handled specially. Instead of loading into a route group, they're registered with Laravel's console kernel via addCommandRoutePaths().

Use console.php to define Artisan commands with closures and schedule tasks:

// Modules/Blog/Routes/console.php
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('blog:cleanup', function () {
    // Clean up old draft posts
});

Schedule::command('blog:cleanup')->daily();

This keeps module-specific scheduling and console commands self-contained within the module.

Broadcasting channels

Files listed in the channels_filenames config (default: ['channels']) define broadcast channel authorization callbacks. When any channels.php files are discovered, Broadcast::routes() is called once after the application boots, then all channel files are loaded:

// Modules/Blog/Routes/channels.php
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('blog.post.{postId}', function ($user, $postId) {
    return $user->can('view', Post::find($postId));
});

Route caching

When Laravel's route cache is active (php artisan route:cache), module routes are included in the cached file. After adding or changing module route files, rebuild the cache:

php artisan route:clear
php artisan route:cache

The package skips route loading entirely when a route cache exists, just like Laravel does with its own route files.

Load order

Routes load in module order. If Shared has order: 1 and Blog has order: 2, Shared's routes register first. This matters when routes have overlapping patterns, since the first matching route wins.

Disabling

Set 'routes.active' => false to disable all module route loading. You can also adjust the patterns, commands_filenames, and channels_filenames arrays to control exactly which files get picked up.

Scroll to top