Many developers faced this problem. When you develop an application, usually for a startup ,and then it is decided to convert it to a multitenant product for SaaS.
Using Laravel you can do the transition very simply without additional packages. Down below you can see the single database approach for a standard API application. The implementation is very straightforward.
The solution I'm proposing utilizes laravel global scopes. You don't need vast knowledge about it, but more information can be found in the Laravel Documentation.
We will create tenants table to save the information about tenants:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tenants');
}
};
Also you need to create tenant model. You can create CRUD endpoints or admin panel for managing tenants. You can also come up with other logic required (like automatic tenants registration).
Most likely you have users in your local database and you might want to give certain users access to certain tenants. It can be done by simple many to many relationship.
Here's an example of the migration:
<?php
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_user', function (Blueprint $table) {
$table->foreignIdFor(Tenant::class)
->constrained()
->cascadeOnDelete();
$table->foreignIdFor(User::class)
->constrained()
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('tenant_users');
}
};
Now we need to attach our tenant related tables to a particular tenant_id. The approach I'm using has a little more redundancy as we add tenant id even to the tables that can have parent tables. However, this approach has two advantages:
But you need to identify which of your tables should be tenant related. Because there can be tables that can be used in any tenant. For example, cities, available avatar images, etc.
<?php
use App\Models\Tenant;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach ($this->getTenantRelatedTables() as $tableName) {
Schema::table($tableName, function (Blueprint $table) {
$table->foreignIdFor(Tenant::class)
->nullable()
->constrained()
->cascadeOnDelete();
});
}
}
public function down(): void
{
foreach ($this->getTenantRelatedTables() as $tableName) {
Schema::table($tableName, function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
});
}
}
public function getTenantRelatedTables(): array
{
return [
// Your tenant related tables list. This list is just an example.
'apps',
'cases',
'categories',
'files',
'reports',
'sections',
'tasks',
];
}
};
Ok, the preparation is finished. Let's continue with the logic. I suggest creating Tenancy folder in the app directory. All tenancy related classes will go there.
The selection of tenant can be done in different ways. I propose a very straightforward approach that uses class static properties.
<?php
namespace App\Tenancy;
class Tenancy
{
public static ?string $tenantId = null;
public static function setTenantId(string $tenantId): void
{
self::$tenantId = $tenantId;
}
public static function getTenantId(): ?int
{
return self::$tenantId;
}
}
As you can see we have only two methods. One sets a tenant, another one gets the tenant that was previously set. If tenant is strictly required in your application, you can throw an error if the tenant is not set.
As we're utilizing Laravel global scope so our tenancy works the same way as Laravel SoftDelete
implementation, we need to create the scope. The scope is very simple:
<?php
namespace App\Tenancy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->getTable().'.tenant_id', Tenancy::getTenantId());
}
}
We use our Tenancy class getTenantId(). The table name is necessary as all our models can have tenant_id and this field might cause confusion if you query certain types of relationships.
Now we want to make certain models - tenant models. I propose creating a model class for it. The class should make sure to apply the scope and also attach certain tenant_id depending on which tenant context the model is currently running from.
<?php
namespace App\Tenancy;
use Illuminate\Database\Eloquent\Model;
class TenantModel extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function (TenantModel $model) {
$model->tenant_id = Tenancy::getTenantId();
});
}
}
Now all you need to do is to extend your current tenant related models from this one. In case your models run booted() method already, make sure to call parent::booted()
in their methods body.
The last part is to define a middleware for our tenant related endpoints and attach it to them. Here's an example of how it can look if we pass tenant id in X-Tenant header:
<?php
namespace App\Http\Middleware;
use App\Models\TenantUser;
use App\Tenancy\Tenancy;
use Closure;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TenantMiddleware
{
public function handle(Request $request, Closure $next): \Symfony\Component\HttpFoundation\Response
{
$tenantId = $request->header('X-Tenant'); // Get tenant id from the header
// Check if current user has access to this tenant
$hasAccess = TenantUser::query()->where([
'tenant_id' => $tenantId,
'user_id' => Auth::id(),
])->exists();
if (!$hasAccess) {
throw new Exception('Unauthorized');
}
Tenancy::setTenantId($tenantId); // Set tenant context
return $next($request);
}
}
And that is it. Now you frontend application just needs to send the selected tenant id
in the header. The list of the available tenants can be fetched by creating tenants list endpoint that would fetch only tenants available for the current user.
Setting tenant context by using http headers is straightforward. But what if your application runs commands in console or in queued jobs. We need to handle setting context there. If you need to run certain operations, such as notifications, by using cron, it can be achieved this way:
foreach (Tenant::all() as $tenant) {
Tenancy::setTenantId($tenant->id);
// Your logic in the tenant context
}
Or if you don't want to filter by a tenant at all, you need to add this query on a model query builder: ->withoutGlobalScope(TenantScope::class)
Because by default all your queries would have WHERE tenant_id = ?
Speaking about jobs, you need to pass tenant id to them as a parameter as there can be jobs that do not require any tenant context.
This approach has another advantage. When your application is already running on production, you can implement the backend without changing something on the frontend. It happens because your tables tenant_id field is nullable and all the tables have null in there. And the default tenant ID can be null too. So all your existing data can keep working. After you finish the frontend tenancy selection logic and the header set, you need to update tenant_id in your database tenant related tables.
If you use Laravel nova and want to make the dashboard tenancy related. You can do it this way:
In the endpoint set up tenant in a session.
$tenantId = $request->get('tenant_id');
session(['nova_tenant_id' => $tenantId]);
return session()->getId();
Add tenant middleware to nova:
// config/nova.php
'api_middleware' => [
'nova',
Authenticate::class,
Authorize::class,
TenancyMiddleware::class,
],
Note: if you want to access the right nova context session, you need to make sure you run it in Nova::serving()
I hope this article can help you with a smooth and quick transition. It helped me to transit several application within a day each. If you need any additional details and clarifications, feel free to write comments.