Creating an API with a modular (folder-by-feature) structure in Laravel

rafaelberaldo

Rafael Beraldo

Posted on April 15, 2023

Creating an API with a modular (folder-by-feature) structure in Laravel

When creating a Laravel project, you'll find that Laravel by default group folders by archtype. Obviously it does works fine, but for larger projects you get a bit lost with files that are tied by a module coming from all over the places.

The structure I'm aiming for looks like this:

app/Modules/{Module}/
├── {Module}.php (model)
├── {Module}Controller.php
├── {Module}Policy.php
├── {Module}Request.php
├── {Module}Routes.php
├── ...
└── Tests/
    ├── {Module}Factory.php
    ├── {Module}FeatureTest.php
    ├── {Module}Seeder.php
    └── {Module}UnitTest.php
Enter fullscreen mode Exit fullscreen mode

For my use case, seeders are only usable in tests. I do like to keep it with as few subfolders as possible.

I'm not going to implement Controller's methods, the focus of this post is to change the default folder structure for an API.

Installing

Start by creating a new project:

composer create-project laravel/laravel laravel-modular
Enter fullscreen mode Exit fullscreen mode

We'll be using SQLite for this. Create it:

touch database/database.sqlite
Enter fullscreen mode Exit fullscreen mode

Then let's edit our .env (copy from .env.example if it didn't automatically), update these vars:

DB_CONNECTION=sqlite
DB_HOST=database/database.sqlite
Enter fullscreen mode Exit fullscreen mode

Now let's create a namespace for our modules folder, edit your composer.json:

"autoload": {
  "psr-4": {
      "App\\": "app/",
+     "Modules\\": "app/Modules/",
      "Database\\Factories\\": "database/factories/",
      "Database\\Seeders\\": "database/seeders/"
  }
},
Enter fullscreen mode Exit fullscreen mode

You can also put it outside the app folder if you'd like, just update the stuff we're doing here accordingly.

Whenever you update composer's autoload stuff, run this command:

composer dump-autoload
Enter fullscreen mode Exit fullscreen mode

You can now run your app with:

php artisan serve
Enter fullscreen mode Exit fullscreen mode

Creating our module

Let us create a Car module:

mkdir -p app/Modules/Car/Tests
php artisan make:model --all Car
Enter fullscreen mode Exit fullscreen mode

The files will be created at the default Laravel dir structure, move every file to the folder we created:

mv -t app/Modules/Car app/Models/Car.php app/Http/Controllers/CarController.php app/Http/Requests/StoreCarRequest.php app/Policies/CarPolicy.php
mv -t app/Modules/Car/Tests database/factories/CarFactory.php database/seeders/CarSeeder.php
Enter fullscreen mode Exit fullscreen mode

For this tutorial I'll leave migration in the default dir, I prefer to visualize all migrations in chronological order, but you can also move to the module folder if you want.

We also only need a single FormRequest for both store and update:

rm app/Http/Requests/UpdateCarRequest.php
mv app/Modules/Car/StoreCarRequest.php app/Modules/Car/CarRequest.php
# also update class name
Enter fullscreen mode Exit fullscreen mode

After that we have to update every namespace to our new folder, update all files in app/Modules/Car to:

namespace Modules\Car;
Enter fullscreen mode Exit fullscreen mode

And all files in app/Modules/Car/Tests to:

namespace Modules\Car\Tests;
Enter fullscreen mode Exit fullscreen mode

You have to do it for every new module. And if you're puting files inside subfolders you have to adequate those as well. Also fix any import errors you might have -- the first module is always the most time consuming.

Run composer dump-autoload to see if there's any file off from PSR-4.

Setup routes

Let's create a modular route:

touch app/Modules/Car/CarRoutes.php
Enter fullscreen mode Exit fullscreen mode

And update the file:

<?php

use Illuminate\Support\Facades\Route;
use Modules\Car\CarController;

Route::resource('cars', CarController::class);
Enter fullscreen mode Exit fullscreen mode

Route::resource maps index/show/store/update/destroy methods in controller to GET/POST/PUT/DELETE endpoints, see more here: https://laravel.com/docs/10.x/controllers#actions-handled-by-resource-controller

For that to work we need a Service Provider, create one:

php artisan make:provider ModuleServiceProvider
Enter fullscreen mode Exit fullscreen mode

And add a boot method to search routes in our modules (you can do the same thing for migrations if you'd like):

public function boot(): void
{
    // Can also use (**) wildcard if you have subfolders
    foreach (glob(base_path('app/Modules/*')) ?: [] as $dir) {
        $modelClassName = class_basename($dir);
        $path = Str::before($dir, "\\$modelClassName");
        $moduleRouteFile = "$path/$modelClassName" . 'Routes.php';

        if (!file_exists($moduleRouteFile)) continue;

        $this->loadRoutesFrom($moduleRouteFile);
    }
}
Enter fullscreen mode Exit fullscreen mode

And add it to the config/app.php under providers:

App\Providers\ModuleServiceProvider::class
Enter fullscreen mode Exit fullscreen mode

You can now test your route! Should be working.

Setup seeders and factories

Update the migration:

Schema::create('cars', function (Blueprint $table) {
  $table->id();
  $table->string('name');
  $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

Update the seeder:

public function run(): void
{
    Car::factory()
        ->count(10)
        ->create();
}
Enter fullscreen mode Exit fullscreen mode

Register it to database/seeders/DatabaseSeeder.php:

public function run(): void
{
    // Note the leading slash
    $this->call([
        \Modules\Car\Tests\CarSeeder::class
    ]);
}
Enter fullscreen mode Exit fullscreen mode

And the factory:

protected $model = Car::class;

public function definition(): array
{
    return [
        'name' => fake()->name()
    ];
}
Enter fullscreen mode Exit fullscreen mode

Note the protected $model, since we're using a custom file structure, we have to declare it. We also need to add a register method to our Service Provider:

public function register(): void
{
    Factory::guessFactoryNamesUsing(function (string $modelName) {
        $modelClassName = class_basename($modelName);
        $namespace = Str::before($modelName, "\\$modelClassName");
        return "$namespace\\$modelClassName\\Tests\\$modelClassName" . 'Factory';
    });
}
Enter fullscreen mode Exit fullscreen mode

You can now run migration and seeder:

php artisan migrate:fresh --seed
Enter fullscreen mode Exit fullscreen mode

Testing

Last part, let's add some tests, run:

php artisan make:test CarFeatureTest
mv tests/Feature/CarFeatureTest.php app/Modules/Car/Tests/CarFeatureTest.php
Enter fullscreen mode Exit fullscreen mode

Don't forget to update namespaces!

Add a function to the test file:

/** @test */
public function get_cars_should_return_success(): void
{
    $response = $this->get('/cars');
    $response->assertStatus(200);
}
Enter fullscreen mode Exit fullscreen mode

Note the /** @test */ comment, PHPUnit won't find the test if you don't add this comment.

Now we have to update phpunit.xml to discorver our tests:

<testsuites>
    <testsuite name="Unit">
-       <directory suffix="Test.php">./tests/Unit</directory>
+       <directory suffix="UnitTest.php">./app/Modules</directory>
    </testsuite>
    <testsuite name="Feature">
-       <directory suffix="Test.php">./tests/Feature</directory>
+       <directory suffix="FeatureTest.php">./app/Modules</directory>
    </testsuite>
</testsuites>
Enter fullscreen mode Exit fullscreen mode

You have to use UnitTest.php or FeatureTest.php suffix, or change phpunit.xml for your use case.

You can now test:

php artisan test
Enter fullscreen mode Exit fullscreen mode

Conclusion

As you can see, Laravel is very powerful and can handle very well custom structures, with little boilerplate IMO. Unfortunately the php artisan make:* commands won't work correctly within modules, but you can add new commands to make it work for you.

That's it for today, hopefully you learned something!


Repository: https://github.com/rafaberaldo/laravel-modular

💖 💪 🙅 🚩
rafaelberaldo
Rafael Beraldo

Posted on April 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related