Create a Multi-Tenant Laravel App With Docker

March 05, 2024
Written by
Lloyd MIller
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Create a Multi-Tenant Laravel App With Docker

When it comes to modern software architecture, multi-tenancy is a significant consideration and is essential for software developers to grasp. But why? Let’s first explain what multi-tenancy is.

Multi-tenancy denotes an architecture where a single instance of an application serves multiple users, or "tenants". In a multi-tenant application, one codebase along with resources is dynamically shared and allocated among all the tenants. Even though the codebase is shared, the isolation of each tenant’s data is paramount. Systems are built in such a way that each tenant’s data and identity are protected.

Multi-tenancy comes with many advantages, including scalability, as all tenants are being served from the same infrastructure, which shares and uses resources in a cost-effective manner. Additionally, a multi-tenant infrastructure allows for better configurability options, as well as reducing the burden for  system administrators to maintain the system for upgrades, patches, and bug fixes.

All these advantages combine to provide system administrators and users with a great experience at a lower cost, and many of these systems charge based on subscription-based pricing models. 

In this article, you’ll be building a multi-tenant system with Laravel and Docker Compose. Why? Well, Docker Compose provides an easy way to set up all the right tools and infrastructure for the app. It also provides the ability to easily move the app between systems, such as from your local development environment to a production environment. We’ll discuss all the tools you’ll need, along with tips on how to set up your database.

Prerequisites

Set up the application

Let’s call this app WebApartment, where users will be able to make their own spaces on the internet. Navigate to the folder where you store your projects and run the following scripts in your terminal; it will generate a new Laravel project and change into the new project directory:

composer create-project laravel/laravel webapartment
cd webapartment

You will now install Laravel Sail, which is a CLI for interacting with your local Docker environment, with the command below. Upon installation, it will prompt you to choose the services you want to install. For the time being, select only pgsql. You will manually construct your docker-compose.yml file, which is utilized to build the Docker instance for this project, shortly.

composer require laravel/sail --dev
php artisan sail:install

Next, install Laravel Breeze, a tool that facilitates the straightforward setup of basic authentication features for Laravel. These features encompass registration, login, password reset, and more. Additionally, for this project, you'll be employing a React frontend, and use Breeze to simplify its setup. Execute the following command:

composer require laravel/breeze --dev

Do not publish the Breeze assets yet. Issues finding assets could arise if you publish the assets outside of the Docker environment that you’ll be using.

Finally, install the Tenancy For Laravel package. This package will assist in implementing our multi-tenancy strategy for the project, and will save time by automating the process of having each tenant create its own database. Install the package by executing the following command:

composer require stancl/tenancy

Update the Docker Compose configuration

docker-compose.yml provides the Docker Compose configuration, outlining the containers that compose our project. Open the file, which you'll find in the root directory of your project, in your editor or IDE of choice. 

Currently, the only services you should find in the file are laravel.test and pgsql (note that these names might differ for you). Beneath pgsql, locate the volumes property. Update it to match the following code.

volumes:
    - 'sail-pgsql:/var/lib/postgresql/tenants/data'
    - './database/entry-point/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'

The changes inform Docker that you will relocate the SQL initialization script from the Laravel Sail package folder to the ./database folder. This allows you to make adjustments for a specific reason, which will be explained shortly.

For the first value, note that the mount point has been modified to /var/lib/postgresql/tenants/data instead of /var/lib/postgresql/data. This adjustment allows the separation of tenant-specific data within the PostgreSQL (pgsql) container, and ensures the segregation of tenant information from the 'landlord' information. 

The term 'landlord' refers to the main PostgreSQL database that will store all application information as well as details on how to connect to each tenant.

Certain environment variables will need to be changed to successfully run our Docker configuration. One such variable is APP_PORT, which should be added to the .env file. Set it equal to an available port on your machine if 80 is already in use. In my case, I set it to 8000. 

If you have PostgreSQL on your local machine or another program running on port 5432, include FORWARD_DB_PORT in the .env file. I have it at 5430, but you can choose any open port. Lastly, modify the value of DB_DATABASE to webapartment

Here's what it should look like:

DB_CONNECTION=pgsql
DB_HOST=pgsql
DB_PORT=5432
DB_DATABASE=webapartment
DB_USERNAME=sail
DB_PASSWORD=password

FORWARD_DB_PORT=5430
APP_PORT=8000

Remember earlier when you edited the entry point for the database volume in the Docker config file? In this step, you’ll create that file. Sail, currently, only has permission to perform CRUD operations with the central database. 

To ensure that Sail has permission to read and write to your tenant databases, create a folder in the database folder called entry-point. Then, in that folder, create a file called create-testing-database.sql. With that done, copy and paste the below code into the new file:

SELECT 'CREATE DATABASE testing'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\gexec

GRANT ALL PRIVILEGES ON  *.* TO 'sail'@'%';
FLUSH PRIVILEGES;

Due to all the changes you’ve made to your config file, you’ll need to rebuild your container images. Afterward, you’ll be able to spin up your new Docker Compose instance. Below are the two commands that will help you do this.

./vendor/bin/sail build --no-cache
./vendor/bin/sail up -d

The -d flag ensures it runs in detached mode, allowing you to use the same terminal window when it’s done spinning up.

The Tenancy package

To publish the resources related to the Laravel Tenancy package, including the tenancy config file, run the following command:

./vendor/bin/sail artisan tenancy:install

You’ll see that we’re using ./vendor/bin/sail artisan instead of php artisan. This is to ensure resources are installed in the context of the Dockerized environment compared to just running php artisan like you’d do on your local environment. This is especially needed for installing frontend resources with NPM to prevent errors saying the needed resources can’t be found.

For this project, you’ll to make a couple of changes:

  • The central application (landlord) to redirect requests to the correct tenant, accessible by their subdomain. 

  • For the application to create and recognize tenant databases, you’ll need to create two new models that extend the ones provided by the package: Domain and Tenant. 

  • Then, in the tenancy.php config file, extend the two models with the custom ones you’ve made

In the new Domain model code below, note that the booted() method is invoked, and there’s a creating() function inside there. This ensures that the domain attribute is modified before the model is created. The Tenancy package has its own way of naming domains and storing that information to the database. If allowed to do this, it will interfere with your goal of standardizing the naming and identifying tenants through the correct subdomains.

The only real change you’ll make to the Tenant model is implementing TenantWithDatabase, which, as the name suggests, is the interface that will enable you to create and identify separate databases for each tenant.

Create the models by executing the following commands:

./vendor/bin/sail artisan make:model Domain
./vendor/bin/sail artisan make:model Tenant

Once that is done, go to app/Models/Domain.php and copy and paste the following code to it:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;use Stancl\Tenancy\Database\Models\Domain as ModelsDomain;

class Domain extends ModelsDomain
{
    use HasFactory;

    public static function booted()
    {
        static::creating(fn ($domain) => 
            $domain->domain = $domain->domain . '.' . config('tenancy.central_domains')[0],
        );
    }
}

Afterward, go to app/Models/Tenant.php and copy and paste the following code to it:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasFactory, HasDatabase, HasDomains;
}

Finally, go to config/tenancy.php and replace the two default models with the ones you created, like this:

<?php

declare(strict_types=1);

use App\Models\Domain;
use App\Models\Tenant;

In the same file, scroll down to the central_domains array and replace what is there with just localhost. This array will come in handy when we create the admin area in the next tutorial.

'central_domains' => [
    'localhost',
],

To ensure the right connection is used when a new tenant database is created, scroll down to the database array where you’ll see template_tenant_connection. Change the value for this key from null to pgsql, like so:

'template_tenant_connection' => 'pgsql',

You have one more optional change that you can make, and that is to have the PostgreSQL manager create separate tenant schemas rather than separate databases, which is the default option. You may want to use this option for the following advantages:

  1. It is more resource-efficient as these schemas are within a single database, which is perfect for small and medium-sized applications.

  2. It’s easier to find and maintain tenant schemas, especially when you’ll need to do arduous database tasks such as backups, migrations, and checking data integrity.  

However, as you can imagine, this option is not so great for larger applications, especially when you need to scale your database horizontally across different servers. It’s not impossible, but it’s a lot more difficult. Also, security is limited because schemas are not isolated like separate databases. This could run you afoul of compliance for many critical applications in healthcare and government.

For this tutorial, tenants are separated by database. However, the Tenancy package also gives users the ability to separate by schema. You will not be doing this for this tutorial. However, here’s how you can achieve this on your own:

Scroll down until you find the managers key. Comment out the first pgsql line that you see and uncomment the second one. It should look like the code below.

'managers' => [
    'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class,
    'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class,
    // 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,

    /**
     * Use this database manager for MySQL to have a DB user created for each tenant database.
     * You can customize the grants given to these users by changing the $grants property.
     */
    // 'mysql' => Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class,

    /**
     * Disable the pgsql manager above, and enable the one below if you
     * want to separate tenant DBs by schemas rather than databases.
     */
     'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database
],

Two other files were made along with the tenancy config file: routes/tenant.php and app/Providers/TenancyServiceProvider.php. You’ll be working on the ServiceProvider file in the next section, but for this article, the tenant.php route file will not be touched.

Alter the Tenant Creation event

For this app, only the "landlord" will be allowed to create new tenants. Hence, you’ll need to make some changes so that a new user can be created during the tenant creation process. The TenancyServiceProvider.php file will be instrumental during this process.

The TenancyServiceProvider.php file is probably one of the most pivotal files you’ll work with, as it acts as a central hub, orchestrating and responding to all events related to tenancy. It handles the registration of events crucial to the tenancy lifecycle. 

If you look at how these events are grouped, you’ll realize that various stages are covered such as tenancy creation, domain creation, database creation, and deletions, etc. Additionally, you will find event listeners under certain events that trigger specific actions or behaviors tied to these events. 

Look at the TenantCreated event, for instance. When this event occurs, you should see a job pipeline that includes the creation of the database and the migration of that database. You’ll be creating a new job that will create a new user in this event.

First, you’ll need to step out of this file for a second and go to the database/migrations folder. There, if the folder hasn’t already been created, create a new folder called tenant. Copy all the files in the migrations folder, except the *create_tenants_table.php and the *create_domains_table.php migration files, and paste them into the tenant folder.

Next, go to your terminal and run the following command to create the job that will be responsible for creating the main user for each tenant:

./vendor/bin/sail artisan make:job CreateTenantMainUser

Once you’ve done that, you will be adding code to app/Jobs/CreateTenantMainUser.php that will create the new tenant main user. In the code sample below, you’ll see that the tenant’s context is passed into the class through the __construct() method. This context is then used in the handle() method, where the callable within the $this->tenant->run() function is responsible for first encrypting the password and creating the user. If you don’t encrypt the password before the user is created, the password will be stored in plain text. 

Overwrite the contents of the CreateTenantMainUser.php file with the code below:

<?php

namespace App\Jobs;

use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class CreateTenantMainUser implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     */
    public function __construct(public Tenant $tenant)
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        $this->tenant->run(function ($tenant) {
            $tenant->password = bcrypt($tenant->password);
            User::create($tenant->only('name', 'email', 'password'));
        });
    }
}

Once that’s done, go back to app/Providers/TenancyServiceProvider.php and add your newly created job to the job pipeline under the TenantCreated event, like so:

Events\TenantCreated::class => [
    JobPipeline::make([
        Jobs\CreateDatabase::class,
        Jobs\MigrateDatabase::class,
        // Jobs\SeedDatabase::class,
        \App\Jobs\CreateTenantMainUser::class, // your new custom job

        // Your own jobs to prepare the tenant.
        // Provision API keys, create S3 buckets, anything you want!

    ])->send(function (Events\TenantCreated $event) {
        return $event->tenant;
    })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
],

Finally, go to config/app.php and scroll down to the providers array and add TenancyServiceProvider like so:

'providers' => ServiceProvider::defaultProviders()->merge([
    /**
     * Package Service Providers...
     */

    /**
     * Application Service Providers...
     */
    App\Providers\AppServiceProvider::class,
    App\Providers\AuthServiceProvider::class,
    // App\Providers\BroadcastServiceProvider::class,
    App\Providers\EventServiceProvider::class,
    App\Providers\RouteServiceProvider::class,
    App\Providers\TenancyServiceProvider::class,
])->toArray(),

It is extremely important that you add this line. Without it, your tenants will be created in the central (landlord) database but the tenant databases won’t be created.

Complete the application setup

There are a few more things that you will need to do to finish the setup of your multi-tenant application. First, you’ll need the resources from Laravel Breeze to be published and installed. You can do this via a regular terminal window by running the following commands:

./vendor/bin/sail artisan breeze:install react
./vendor/bin/sail npm install
./vendor/bin/sail npm run dev

Finally, you’ll need to run the migrations. But first, add the following line to the Schema::create() functions in the up() method in both the *create_domains_table and *create_tenant_table migration files (best to add it after the $table->timestamps() line):

$table->softDeletes();

Now, run your migrations by doing the following in a new terminal tab or window:

./vendor/bin/sail artisan migrate
docker-desktop-containers-list.png
docker-desktop-containers-list.png

You also have the option of running a terminal window from the Docker environment. The easiest way to do this is by opening Docker Desktop, clicking Containers in the left-hand side menu, navigating to the webapartment instance, expanding it if necessary, and clicking on the Action button (the three vertical dots) beside the laravel.test container. A menu will appear and you will then click on Open in Terminal. A full-window terminal should appear and you’ll be able to run commands as normal without appending sail to the front.

docker-desktop-view-running-container.png
docker-desktop-view-running-container.png

Create new tenants

To test that our implementation works, we’ll be using Laravel Tinker, which should already be installed. Tinker is an invaluable tool that will help you interact with your Laravel application at the command line. It’s an interactive shell that will allow you to dynamically experiment, test, and debug code snippets. 

In this way, you can quickly get insights into your application's data and behavior. For this project, you will use Tinker to interact with Eloquent ORM and the Tenancy package to ensure that it is working properly in the creation of new tenants.

First, open Tinker by running the following command in your terminal:

./vendor/bin/sail artisan tinker

Then, you’ll create your first tenants along with their default users with their corresponding databases by running the following commands, after replacing the placeholders with your name and email, respectively:

 

$tenant1 = App\Models\Tenant::create(['id' => 'foo', 'name' => '<<YOUR NAME>>', 'email' => '<<YOUR EMAIL>>', 'password' => 'password']);
$tenant1->domains()->create(['domain' => 'foo']);

$tenant2 = App\Models\Tenant::create([‘name’ => '<<YOUR NAME>>', ‘email’ => '<<YOUR EMAIL>>', ‘password’ => ‘password’]);
$tenant2->domains()->create(['domain' => 'bar']);

Your terminal should look something like this for the first tenant:

tenant-1-output.png
tenant-1-output.png

You might have realized that under the Domain model, the value for domain is foo.localhost. This wasn’t magic; it was actually due to the function that you added to the booted() method in the Domain model. You’ll also see that the password was hashed due to the Job that you added to the Job pipeline in the TenancyServiceProvider.php file. 

But why does tenancy_db_name have a value of tenantfoo and not just foo? Go back to config/tenancy.php, scroll down until you get to the database array. Scroll down some more and you’ll see the prefix and suffix keys, with tenant as the value for prefix. It either prefixes and/or suffixes the tenant ID, which in this case, is foo. You can change the prefix or the suffix to anything you want.

Now, look at the second tenant below. In this instance you did not add an ID, yet one was made automatically. If you look at the tenancy config file again and scroll to the top, you’ll see the `id_generator` key with Stancl\Tenancy\UUIDGenerator::class as a value. This is the tool that is responsible for generating an ID for the Tenant model when none is inputted. Also, as seen in the example above, the ID was used to create a name for the new tenant database.

tenant-2-output.png
tenant-2-output.png

So, great! You can see the new tenants being created in our central database, but you may be wondering how you’ll know that the tenant databases were actually created. Go back to Docker Desktop and open a new terminal for the pgsql container. 

Next, once you run the command below, you should see several databases, with one being the main central database (webapartment) and two being the new tenant databases that you created above.

psql -U sail -l

You can also get the ID of the pgsql container and run this command in a new terminal window:

docker compose exec -it pgsql*PGSQL_CONTAINER_ID* psql -U sail -l/bin/sh
psql-list.png
psql-list.png

That's how to set up a Laravel multi-tenant application with Docker Compose

The Tenancy For Laravel package offers a powerful method to swiftly set up a multi-tenant application utilizing multiple databases. As outlined earlier, this architectural approach proves highly beneficial for scaling extensive applications, while ensuring the secure isolation of each tenant's data.

The importance of multi-tenancy in contemporary web applications cannot be overstated. As businesses progress, efficiently managing and scaling applications for numerous tenants becomes crucial.

The subsequent article will delve further into enhancing this application by implementing CRUD operations with the assistance of Filament. You will gain insights into the fundamentals of this robust package for effective tenant management.

Lloyd Miller is an experienced web developer specializing in Laravel and React. He enjoys creating consumer-facing products, especially in the e-commerce space. You can learn more about him on LinkedIn.