Multi-tenant databases with Laravel Jetstream

Posted in Laravel on Sep 12, 2020

Laravel Jetstream is the latest in a long line of fantastic first-party open source packages from Taylor and the (growing) Laravel team. Jetstream includes an option to add team functionality to your app, (really useful for SaaS apps!). This article is going to walk you through how to adapt a Jetstream enabled Laravel app to use multii-tenant databases.

What is the Teams Feature?

The teams functionality has been extracted and improved from the premium product Laravel Spark.

Why Multi-Tenant Databases

...

Database Connections

We need two database connections: one for the 'landlord' database and for the 'tenant'.

  1. Duplicate the default mysql connection and rename this to tenant.
  2. Rename the mysql connection to landlord
  3. Change the database name of the landlord connection:
'database' => env('DB_LANDLORD_DATABASE', 'myapp_landlord'),
  1. In your .env file replace the DB_DATABASE environment variable like so:
DB_LANDLORD_DATABASE=getpaid2_landlord
DB_TENANT_DATABASE_PREFIX=getpaid2_
  1. Set default connection to the landlord database:
DB_CONNECTION=landlord

Migrations

Publish the landlord migration from the Spatie package.

php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="migrations"

This migration will be published to database/migrations/landlord. From now one, the database/migrations directory will be used exclusively for the tenant migrations. Assuming you have a clean Laravel 8+ Jetstream installation, move all the migrations to the new landlord directory. If you are working on an existing application you will need to decide which migrations should be moved to the landlord directory. As a general rule anything related to the framework should be in the landlord directory e.g. teams, users, failed jobs, personal acceess tokens. Application data for a tenant should not be in the landlord schema.

Before we migrate the database we need to make a couple of modifications:

  • Remove the domain column, it is unnecessary as we are using Jetstream to select the tenant.
  • Add a team_id column to relate the tenant to a team
$table->foreignId('team_id')->constrained()->cascadeOnDelete();

Migrate the landlord database

php artisan migrate --path=database/migrations/landlord --database=landlord

Configuration

First we need to publish the base configuration file.

php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="config"

Specify the connections names to use:

    'landlord_database_connection_name' => 'landlord',
    'tenant_database_connection_name' => 'tenant',

We need to use the provided SwitchTenantDatabaseTask:

	'switch_tenant_tasks' => [
        \Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class,
    ],

Models

We need to add a relationship to the tenant model from the Team model. Whilst we are here we can the trait UsesLandlordConnection. The User model should have this trait as well.

// ...
use Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection;
use Spatie\Multitenancy\Models\Tenant;

class Team extends JetstreamTeam
{
	use UsesLandlordConnection;
    

	// ...
	
    public function tenant(): HasOne
    {
        return $this->hasOne(Tenant::class, 'team_id');
    }
}

Add the UsesTenantConnection to the models which should live in the in the tenant databases.

Creating a tenant database

The Spatie package is deliberately unopionated and therefore doens't provide a mechanism for creating the tenant databases. As we are using Jetstream to provide our appication with team functionality we need to create a tenant database when a team is created. Fortunately, Jetstream provides a perfect event we can listen for.

I highly recommend you use a queued listener for this. If something goes wrong creating the tenant database it will be easier to retry.

php artisan make:listener --queued --event="\\Laravel\\Jetstream\\Events\\TeamCreated" CreateTenantForTeam

Use this snippet for the handle method of the listener:

	public function handle(TeamCreated $event)
    {
        $tenant = Tenant::query()->firstOrCreate([
            'name' => $event->team->name,
            'team_id' => $event->team->id,
        ]);

        $databaseName = sprintf('tenant_%05d', $tenant->id);

        // create if database does not exist
        DB::unprepared("CREATE DATABASE IF NOT EXISTS $databaseName;");

        Artisan::call("tenants:artisan --tenant={$tenant->id} -- \"migrate --database=tenant\"");

        $tenant->update([
            'database' => $databaseName
        ]);
    }

Register the listener in your EventServiceProvider:

	protected $listen = [
        // ...
        \Laravel\Jetstream\Events\TeamCreated::class => [
            CreateTenantForTeam::class
        ]
    ];

Use middleware to select the current tenant

The Spatie provides a mechanism for automatically determining the current tenant but this approach won't work with Jetstream as the code is executed during booting of the application. A sensible approach is to use middleware that runs after the session has been started.

/** insert middleware code here **/

Now register this middleware:

	protected $routeMiddleware = [
    	// ...
        'tenant' => \App\Http\Middleware\DetermineCurrentTenant::class,
	];

Finally, use the new middleware for all authenticated routes. A route group

© 2020 Mark Fullbrook — Powered by Statamic and Tailwind