DEV Community

Saad
Saad

Posted on

Laramod: Laravel modules without the magic that still feels magical

Why

Every Laravel application starts out tidy. Then it grows, and one day app/Http/Controllers has eighty files in it, billing sits beside the blog, the blog sits beside the support desk, and the only thing the files of a feature share is a prefix in their names.

Laravel sorts code by what it is: controllers here, models there, migrations somewhere else. That is perfect for a small application. For a large one you want to sort by what the code is for. Everything the blog needs, routes, controllers, models, migrations, views, tests, in one directory that you can read on its own, hand to a colleague, delete, or move into a package.

That is all a module is. And Laravel has every part you need to build one: a service provider can load routes, views, translations and migrations from any directory. A module system is mostly an agreement about where those calls are made. So the real question about a module package is how much machinery it puts around that agreement.

Here is what I wanted from one:

  • Open one file and see which modules the application has, and in which order.
  • Open one class and see everything a module contributes.
  • Nothing to rebuild or clear when something does not show up.
  • A module that is a directory of ordinary Laravel code, not a small package with a composer.json of its own.
  • A module installed with Composer that works exactly like one in my own repository.

These expectations come from years of building products. Django, with its apps, is a great example of a modular architecture. Every time I started on a large product in anything, I ended up building something similar by hand. None of the Laravel packages I tried gave me all five points, so I wrote Laramod.

The existing ones

Two packages cover most of what people use today. Both are good work that the community has leaned on for years, and Laramod takes inspiration from them. They made choices that suit many teams, but I wanted something simpler: modules that plug in with one line, and code that is as easy to use from another module as from the application itself.

nwidart/laravel-modules

It has followed the framework from Laravel 5.4 to 13. Its documentation puts the idea in one sentence: "Each module is a mini Laravel application with a predictable structure."

And that is what you get. A generated module has a module.json, a composer.json, a vite.config.js and its own service providers, a BlogServiceProvider and a RouteServiceProvider among them. Modules are switched on and off with module:enable and module:disable, the state lives in modules_statuses.json. Since v11 the modules are autoloaded through the Wikimedia Composer merge plugin, which merges the composer.json of every module into the application's. Files are generated with a command family of its own, module:make-model, module:make-controller and so on.

It is mature, it is documented, and nearly every question about it has been answered somewhere already. The price is weight. A module is a lot of files before you have written a line, and the truth about a module is spread over several of them: module.json, the status file, composer.json, the providers. When a route is missing, there are many places to look.

InterNACHI/modular

Modular is the lighter take, and I like it a lot. It does not invent a module system, it uses the two that are already there: a module is a real Composer package in app-modules/, pulled in through a path repository, and it is booted by Laravel's package discovery.

make:module writes the module, adds a path repository and a require to your composer.json, and reminds you to run composer update. From then on, conventions do the work: commands, migrations, factories, policies, Blade components and event listeners are discovered on their own, and modules:cache makes that discovery faster. Its best idea is that the stock generators take a --module= option, php artisan make:model Post --module=blog, instead of a second family of commands. I took that idea gladly.

The price here is different. Every module still has its own composer.json, a src/ directory and a service provider. A new module is a Composer operation. And what a module contributes is decided by convention, so the answer to "what does this module provide?" is its directory tree, plus a cache that may or may not be fresh.

That last point is what the two have in common. Both answer the question by looking at the file system. Laramod answers it by looking at a class.

Laramod

This is a whole module:

class BlogModule implements Module, ProvidesRoutes, ProvidesMigrations
{
    public function name(): string
    {
        return 'blog';
    }

    public function path(): string
    {
        return __DIR__;
    }

    public function routes(Router $router): void
    {
        require __DIR__.'/routes/web.php';
    }

    public function migrations(): array
    {
        return ['Database/Migrations'];
    }
}
Enter fullscreen mode Exit fullscreen mode

And this is how the application knows about it, in bootstrap/modules.php:

use Modules\Blog\BlogModule;

return [
    BlogModule::class,
];
Enter fullscreen mode Exit fullscreen mode

That is the whole mechanism. There is no auto-discovery, no manifest, no status file, no cache and no composer.json per module. The order of the list is the order the modules are wired in. To switch a module off, you remove its line.

Contracts instead of conventions

A module gains a capability by implementing a contract: ProvidesRoutes, ProvidesApiRoutes, ProvidesGlobalMiddlewares, ProvidesMigrations, ProvidesSeeders, ProvidesCommands, ProvidesViews, ProvidesTranslations, ProvidesConfig, ProvidesViteEntries, and Ordered for a module that has to come first or last.

Nothing is looked up on disk. A module without ProvidesViews has no views, whatever its directories contain. It sounds strict, and it is the point: the class line of a module is its table of contents, your editor can jump to every part of it, and "why is this not loaded?" has exactly one place to look.

Views, translations and Blade components use the module's name as their namespace, the way packages always have:

view('blog::posts.index');
__('blog::messages.title');
Enter fullscreen mode Exit fullscreen mode

It stays out of the way

composer require protibimbok/laramod and php artisan laramod:init prepare the application: the Modules/ directory, one PSR-4 line in composer.json, the module test suites in phpunit.xml. php artisan make:module Blog writes a module and lists it.

After that you use the framework's own generators:

php artisan make:model Post -mfs --module=Blog
php artisan make:controller PostController --module=Blog
php artisan make:test PostTest --module=Blog
Enter fullscreen mode Exit fullscreen mode

Everything a command creates along the way stays in the module, so make:model -mfs keeps the migration, the factory and the seeder there too. Without the option, the commands behave exactly as they do in a stock application.

The same restraint goes for the rest. Module seeders only run where you ask for them, with one Seeder::runSeeders() in your own DatabaseSeeder. Module routes point to controller actions, so route:cache keeps working, and once the routes are cached the route methods of the modules are not called at all.

Installed modules are the same thing

A module from a Composer package is a class too, and it goes into the same list. The one difference is that it is read-only: make:* --module refuses to write into vendor, and what such a module provides is copied into the application with the stock vendor:publish, under tags like blog-views, blog-config and blog-migrations.

Front-end tooling

The front end is where module systems usually stop being pleasant. The PHP side of a module is easy to move into a directory. Its JavaScript and CSS are not, because the bundler lives at the root of the project and knows nothing about modules. The usual way out is to make every module a small front-end project: its own package.json, its own vite.config.js, its own npm install, its own build directory. It works, and it means several node_modules, several builds to run in the right order, and two modules that both use Alpine or React shipping it twice.

I wanted the opposite: one package.json, one vite.config, one npm run dev, one build, and modules that still own their assets. That is a package of its own, laramod-vite-plugin, and installing it is one changed line, because it wraps laravel-vite-plugin and takes the same options:

-import laravel from 'laravel-vite-plugin';
+import laramod from 'laramod-vite-plugin';

 export default defineConfig({
     plugins: [
-        laravel({
+        laramod({
             input: ['resources/css/app.css', 'resources/js/app.js'],
             refresh: true,
         }),
     ],
 });
Enter fullscreen mode Exit fullscreen mode

Entries are declared where everything else is. A module says which files it wants built, relative to itself, and loads them the same way:

public function viteEntries(): array
{
    return ['resources/js/app.js'];
}
Enter fullscreen mode Exit fullscreen mode
{{ Modules::vite('blog', 'resources/js/app.js') }}
Enter fullscreen mode Exit fullscreen mode

Modules::vite() returns what @vite returns, so there is the dev server with hot reloading in development and the hashed files of the manifest in production. The view does not know whether the module lives in Modules/Blog or in vendor/acme/blog, so it keeps working when the module moves into a package. Loading a file the module has not declared is an error and not a silent success: the dev server would happily serve it, the production build would not contain it, and that is a bug you want to meet on your own machine.

The plugin asks, it does not guess. Vite runs in Node and the modules are decided in PHP, so the plugin runs php artisan laramod:vite and gets the modules, their paths and their entries as JSON. No directory is scanned, and the list in bootstrap/modules.php stays the only truth, for the bundler too. For a build stage without PHP, a Node-only Docker image for example, you write that JSON to a file once and hand it to the plugin.

Development feels like one application. With refresh: true the page also reloads when a module's views, translations, routes, Blade components or Livewire components change. When you add a module or change a module class, the dev server restarts by itself and asks Artisan again. And when Artisan fails, because the file is half edited, Vite logs the error and keeps the running server instead of dying on you.

Every module gets an alias.

import { greeting } from '@blog/js/greeting.js'; // Modules/Blog/resources/js/greeting.js
Enter fullscreen mode Exit fullscreen mode

This is the front-end half of using code across modules: a component or a helper of one module is one import away from another, with no relative paths climbing out of one module and into the next. The aliases are mirrored into the paths of your tsconfig.json or jsconfig.json, so the editor and the type checker follow them too, and only the plugin's own lines in that file are ever touched.

One build has a consequence that is easy to overlook. Every module shares the same copy of every dependency. There is one React, one Alpine, one instance of whatever store you use, so a context or a registry created in one module is the same object in another. The next section is built on exactly that.

It also has a cost, and it is a deliberate one: a module has no package.json, so the JavaScript of an installed module can only import npm packages the application has installed itself. A packaged module has to say in its README what it needs. I find that an honest trade. The application owns its dependency tree, and nothing in vendor gets to grow a second one.

Installing those packages by hand is the rough edge, and I plan to make it easier: a way for a module to say which npm packages it needs, and for the application to install them in one step, without giving up the single dependency tree. How that should look is still open. If you have an opinion, or a setup this would break, you are welcome to start a discussion on the GitHub repository.

Modules that build on each other

Splitting an application into modules is the easy half. The hard half is letting them work together without tying them into a knot. Using a class of another module needs nothing at all in Laramod: all modules live under one autoload root, so use Modules\Blog\Models\Post; works from anywhere, with no require between packages. The interesting case is the other direction, a module that wants other modules to plug into it.

The contracts are not a closed list. They are plain interfaces, so a module can bring one of its own. Say a Search module wants every other module to be able to put its models into the index:

namespace Modules\Search\Contracts;

interface ProvidesSearchables
{
    public function searchables(): array;
}
Enter fullscreen mode Exit fullscreen mode

A module that wants in implements it, next to everything else it provides:

class BlogModule implements Module, ProvidesRoutes, ProvidesSearchables
{
    public function searchables(): array
    {
        return [Post::class];
    }
}
Enter fullscreen mode Exit fullscreen mode

And the search module asks the registry who does:

foreach (Modules::providing(ProvidesSearchables::class) as $module) {
    $index->add($module->searchables());
}
Enter fullscreen mode Exit fullscreen mode

That is the whole extension mechanism: an interface, and one call that returns the modules implementing it, in the order they are listed. There are no events to subscribe to, no hook names to remember and no registration step to forget. The blog does not know how the search works, the search has never heard of the blog, and your editor can still list every module that plugs in, because it is a "find implementations" away.

The front end works the same way, thanks to the single build: a module imports from another through its alias, and both ends get the same instance. This is roughly what a module adding its pages to an admin looks like:

// Modules/Blog/resources/js/dashboard.tsx
import { registerModule } from '@admin/js'

registerModule({
  id: 'blog',
  menuFilter: (menu) => [...menu, { label: 'Blog', items: [{ label: 'Posts', path: '/blog' }] }],
  routes: [{ path: 'blog', element: <Posts /> }],
})
Enter fullscreen mode Exit fullscreen mode

That snippet is from laramod-admin, a React and shadcn/ui admin that is itself a Laramod module. It is released too, composer require protibimbok/laramod-admin, and may get a post of its own.

Guidance for AI agents

A module carries its instructions for coding agents in an ai-workflow directory of its own, and with Laravel Boost the application's agent is told to run laramod:list --json and to read a module's workflow before it touches the module. There are no AGENTS.md files scattered through the tree.

Comparison

What is a module? In laravel-modules, a mini application: module.json, composer.json, vite.config.js, service providers. In Modular, a Composer package: composer.json, src/, a service provider. In Laramod, a class in a directory.

How does the application know about it? laravel-modules scans for modules and keeps their state in modules_statuses.json. Modular uses a path repository plus package discovery, so a new module needs a composer update. Laramod has a PHP array you edit by hand, and make:module adds the line for you.

How is a module wired? laravel-modules: by the module's service providers. Modular: by conventions that are discovered, with an optional cache. Laramod: by the contracts the module class implements, and by nothing else.

Generators. laravel-modules has its own module:make-* family. Modular and Laramod both extend the stock make:* commands with --module.

Switching a module off. laravel-modules has module:disable, and it is the only one of the three where this is a feature. In Modular you remove the package, in Laramod you remove the line.

Front-end assets. laravel-modules gives every module its own package.json and vite.config.js. A module is built on its own into public/build-blog and loaded with module_vite(), or a published vite-module-loader.js collects the paths every module's config exports into the root build. Modular's README leaves assets to you. Laramod has one build and one package.json for the application, fed by the entries the modules declare in PHP, with a @module alias, page reloads and dev-server restarts for every module.

Modules from Composer. In Modular every module is a Composer package already. In Laramod an installed module is listed like a local one and is read-only. In laravel-modules, modules are primarily directories of the application.

Framework support. laravel-modules reaches back to Laravel 5.4, Laramod needs Laravel 13 and PHP 8.3.

When not to pick Laramod

Laramod is at v0.1. If you are on an older Laravel, if you need modules that are switched on and off at runtime, or if you want a package with ten years of answered questions behind it, take laravel-modules. If your modules are truly packages in waiting and you want Composer to be the boundary between them, Modular is a very good fit.

Pick Laramod if you would rather write one line than wonder why a convention did not fire. It trades a little typing for being able to read, in two files, everything your application is made of.

Try it

composer require protibimbok/laramod
php artisan laramod:init
php artisan make:module Blog
Enter fullscreen mode Exit fullscreen mode

Laramod is young, and what it becomes depends on the applications people build with it. If you are interested, if you get stuck while trying it, or if your project needs something it does not do yet, share your ideas: open a discussion or an issue on GitHub, or leave a comment here. I read all of them, and I am glad to help.

Top comments (0)