I am not the first with the idea, but I wanted to make it myself as an exercise.
The idea behind a modular folder structure is creating package-like folders that have almost no ties with other parts of the applications. For example they could share the database, but they don't share tables.
This way it is easy to reuse them or split them from the application to put on a more powerful server if needed.
Discovering the module parts step by step
Most people that know Laravel know the AppServiceProvider.
I'm named it ModuleServiceProvider
and only have the boot method.
Because I'm going to use modules in the app directory I removed the Http/Controllers and Models directories. For this post I'm going to use a User module to demonstrate the folder structure.
- app
- Providers
- ModuleServiceProvider
- User
- Providers
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class ModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(base_path('app')));
$files->setMaxDepth(2);
foreach ($files as $file) {
// code in the sections below
}
}
}
I am using the iterator classes because then I have more configuration options than using glob.
One of those options is setmaxDepth
.
I want a flatter folder structure in the modules, so all directories that are significant are a direct children of the module directory.
Routes
if($file->isFile() &&
str_contains($file->getPathname(), 'routes') &&
$file->getFilename() == 'web.php') {
Route::middleware('web')->group($file->getRealPath());
}
I want the routes in the modules to have the middleware without the need the add it explicitly.
You can add a similar if for the api.php file, or any other group of routes you want.
The controller are no problem because they are configured by the routes.
use App\User\UserController;
use Illuminate\Support\Facades\Route;
Route::get('/login', new UserController()->login(...));
- app
- Providers
- ModuleServiceProvider
- User
- routes
- web.php
- UserController.php
- routes
- Providers
Views
if ($file->isDir() && str_contains($file->getPathname(), 'views')) {
$namespace = strtolower(str_replace(['/app/app/', '/views'], '', $file->getPath()));
$this->loadViewsFrom($file->getRealPath(), $namespace);
}
To make it more clear that the views in the modules are used, I added the name of the module in lowercase as namespace.
namespace App\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class UserController extends Controller
{
public function login(Request $request) {
return view('user::login');
}
}
- app
- Providers
- ModuleServiceProvider
- User
- routes
- web.php
- views
- login.blade.php
- UserController.php
- routes
- Providers
Migrations
Remember before I mentioned modules don't share tables. So it is logical to move the migrations to the module folders.
if($file->isDir() && str_contains($file->getPathname(), 'migrations')) {
$this->loadMigrationsFrom($file->getRealPath());
}
- app
- Providers
- ModuleServiceProvider
- User
- migrations
- create_users_table.php
- routes
- web.php
- views
- login.blade.php
- UserController.php
- migrations
- Providers
Localisation
The last thing I wanted in the modules are the language files.
if($file->isDir() && str_contains($file->getPathname(), 'lang')) {
$this->loadTranslationsFrom($file->getRealPath());
}
- app
- Providers
- ModuleServiceProvider
- User
- lang
- en
- user.php
- en
- migrations
- create_users_table.php
- routes
- web.php
- views
- login.blade.php
- UserController.php
- lang
- Providers
Because Laravel has groups to provide context for the localisation strings, I choose to match the language filename with the module name. This gives translation keys like {{ __('user.label.email') }}
.
The full ModulesServiceProvider
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class ModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(base_path('app')));
$files->setMaxDepth(2);
foreach ($files as $file) {
if ($file->isDir() && str_contains($file->getPathname(), 'views')) {
$namespace = strtolower(str_replace(['/app/app/', '/views'], '', $file->getPath()));
$this->loadViewsFrom($file->getRealPath(), $namespace);
}
if($file->isDir() && str_contains($file->getPathname(), 'migrations')) {
$this->loadMigrationsFrom($file->getRealPath());
}
if($file->isDir() && str_contains($file->getPathname(), 'lang')) {
$this->loadTranslationsFrom($file->getRealPath());
}
if($file->isFile() && str_contains($file->getPathname(), 'routes') && $file->getFilename() == 'web.php') {
Route::middleware('web')->group($file->getRealPath());
}
}
}
}
I left out the assets because the current way of adding them to the templates is with @vite and there you add the full paths for the assets.
For the classes you can structure the folders as you want.
Conclusion
After I was satisfied with the result, I looked if someone did it already. And I found Laravel-modules. They create almost a full Laravel application folder structure in each module directory. While it will be very familiar for most of the Laravel developers, I think find it a bit too much.
The benefit of that package is that they have ported almost all artisan methods to work with modules.
One place where I think there is going to be a dependency is in the views with the layout. At the moment I don't have a solution.
You can adopt a standard name for the layout file, and in case that the module becomes a service you could create an empty file.
Another thing that could be an issue is modules in modules. That is a good way to split modules with a lot of code.
But for now I like the result.
Top comments (0)