When you develop a multi-tenant application, it's easy for the code of your central application to get entangled with the tenant code. Or you start splitting everything into Central and Tenant folders.
Models
|-> Central
|-> Tenant
but there is a better way: modules.
In this post, we will talk about how to do that.
The stack I'm using:
- Laravel 13
- stancl/tenancy v4
- Pest 4
How a module is usually structured
First, we need to decide on a structure. Most developers structure their modules like this:
Modules/Billing/
├── src/
│ ├── Domain/
│ ├── Application/
│ └── Http/
├── config/
├── database/
├── routes/
└── tests/
I decided to structure it differently, using the structure you are already used to working with: the default Laravel structure.
modules/
└── Tenancy/
├── app/
│ ├── Http/
│ │ └── Controllers/
│ │ └── Controller.php
│ ├── Models/
│ │ ├── SomeModel.php
│ │ └── AnotherModel.php
│ └── Providers/
│ ├── TenancyModuleServiceProvider.php
│ └── TenancyServiceProvider.php
├── config/
│ └── tenancy.php
├── database/
│ ├── factories/
│ ├── migrations/
│ └── seeders/
└── routes/
└── tenant.php
With this structure, you can easily reason about where to put your code. And if you use PHPStorm, you can use its scopes feature to list only the module code:
The feature is located in Settings -> Appearance & Behavior -> Scopes.
This way, the code of the central application won't bother you.
I decided to leave the frontend code and the tests in there normal folders resources and tests. The tenancy code will never be separated from the central application, because one cannot live without the other, so I like to keep my frontend and my tests in a single place. You can still include those folders in the scope, so only the tests and resources related to Tenancy are shown.
Now that the structure is ready, we need to register the module.
How to register the Tenancy module
The stancl/tenancy v4 package comes with a TenancyServiceProvider and a config file named tenancy.php. We will move them to modules/Tenancy/app/Providers and modules/Tenancy/config respectively. We also need to create our own service provider to register the routes, config files, and providers. It will look something like this:
<?php
declare(strict_types=1);
namespace Modules\Tenancy\App\Providers;
use Illuminate\Support\ServiceProvider;
final class TenancyModuleServiceProvider extends ServiceProvider
{
protected string $moduleRoot = __DIR__.'/../../';
/**
* @var array<int, class-string<TenancyServiceProvider>>
*/
private array $providers = [
TenancyServiceProvider::class,
// Other service providers...
];
public function register(): void
{
$this->registerConfigFiles();
foreach ($this->providers as $provider) {
$this->app->register($provider);
}
$this->loadRoutesFrom($this->moduleRoot.'routes/tenant.php');
}
private function registerConfigFiles(): void
{
$files = glob($this->moduleRoot.'config/*.php') ?: [];
// use set to register the config files
foreach ($files as $file) {
config()->set(basename($file, '.php'), require $file);
}
}
}
Then register the module's provider in bootstrap/providers.php, and remove the old App\Providers\TenancyServiceProvider and config/tenancy.php from the central app:
return [
App\Providers\AppServiceProvider::class,
Modules\Tenancy\App\Providers\TenancyModuleServiceProvider::class,
];
After moving the config file, set the migration paths in it to the following:
'migration_parameters' => [
'--force' => true, // This needs to be true to run migrations in production.
'--path' => [base_path('modules/Tenancy/database/migrations')],
'--schema-path' => base_path('modules/Tenancy/database/schema/tenant-schema.dump'),
'--realpath' => true,
],
Also make sure you set CACHE_STORE in phpunit.xml to file if you're using the RedisTenancyBootstrapper, because it does not work otherwise.
Now we can move on to Composer. After making these updates, you need to register the namespaces in composer.json and run composer dump-autoload:
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/",
"Modules\\Tenancy\\App\\": "modules/Tenancy/app/",
"Modules\\Tenancy\\Database\\Factories\\": "modules/Tenancy/database/factories/",
"Modules\\Tenancy\\Database\\Seeders\\": "modules/Tenancy/database/seeders/"
}
},
Structuring the tests
As I said before, I like to keep the tests in the root tests folder. An easy way to structure them is to split them like this:
tests
├── Central
│ ├── Browser
│ ├── Feature
│ └── Unit
└── Tenant
├── Browser
├── Feature
└── Unit
Using PHPStorm's scopes feature, you can include the Tenant folder, so when we select the scope, the modules/Tenancy folder and its tests will be the only ones shown.
If you decide to do this, update the test suites in the phpunit.xml file:
<testsuites>
<testsuite name="Central">
<directory>tests/Central</directory>
</testsuite>
<testsuite name="Tenant">
<directory>tests/Tenant</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
<directory>modules/Tenancy/app</directory>
</include>
</source>
Making your tools aware of the module
Laravel Idea
If you use the Laravel Idea plugin, you can make it module-aware. In my case, I'm using PHPStorm.
First, configure how you are using modules. You can find it in Languages & Frameworks -> Laravel Idea -> Module System. Configure it like below:
Next, go to Languages & Frameworks -> Laravel Idea -> Code Generation -> Standard Generation. Set the "Available in modules" flag to true for all the code generation types, and include the path where you want the code to be generated.
Now, just use the shortcut and you will be able to generate code really fast for the Tenancy module. When generating some code, you will be prompted to choose between generating it for the central application or for a specific module.
You can also change the templates used for the generated code, which gives you a lot of freedom when it comes to customization.
Octane
If you're using Octane, you need to set the --watch flag in the server command, like this:
/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --watch --server=swoole --host=0.0.0.0 --port='${APP_PORT:-80}'
and add the paths of the module files to the Octane config, like below:
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'modules/Tenancy/routes',
'modules/Tenancy/app',
'modules/Tenancy/database/**/*.php',
'composer.lock',
'.env',
],
This way, the workers get reloaded when you change the routes or the tenant code.
PHPStan
If you're using PHPStan, you also need to include the Tenancy code. You can do that in the phpstan.neon file in the root of your project:
parameters:
paths:
- app/
- bootstrap/app.php
- config/
- database/
- routes/
- tests/
- modules/Tenancy/app
- modules/Tenancy/database
- modules/Tenancy/routes
Rector
Like the other tools, Rector needs to know about the module files. You can add the paths to the withPaths method in the rector.php file in the root of your project, like below:
return RectorConfig::configure()
->withPaths([
__DIR__.'/app',
__DIR__.'/routes',
__DIR__.'/database',
__DIR__.'/config',
__DIR__.'/tests',
__DIR__.'/modules/Tenancy/app',
__DIR__.'/modules/Tenancy/routes',
__DIR__.'/modules/Tenancy/database',
])
->withComposerBased(laravel: true)
->withImportNames()
->withPhpSets(php85: true)
->withPreparedSets(
deadCode: true,
codeQuality: true,
codingStyle: true,
typeDeclarations: true,
typeDeclarationDocblocks: true,
)
->withRules([
SortTraitUsesByLengthRector::class,
SortAttributesByLengthRector::class,
]);
Conclusion
I think that's all. If I missed something, please let me know in the comments.
Structuring the tenancy code this way makes development easier and faster, because you avoid splitting everything into Central and Tenant folders:
Models
├── Central
├── Tenant
Http
├── Central
├── Tenant
Providers
├── Central
├── Tenant
If this post was useful, please like it, and leave a comment.
Thanks,
www.itsabderrahimel.com




Top comments (0)