Multi-Tenant Laravel on Ubuntu 18.04 with NGINX, MariaDB, and PHP 7.3

Configuring Laravel as a multi-tenant application

Alex Elkins
May 13 · 9 min read

In today’s world, it is very common for online businesses to present themselves as software as a service, or SaaS. SaaS provides the opportunity to use a single codebase for multiple companies, or tenants. This type of architecture provides multiple benefits for developers. Having a single codebase means that code isn’t replicated, documentation can have a single source of truth, and, ideally, adding a new tenant will require little to no work.

As a result of this guide, you will have a Laravel instance with multi-tenancy, one system database (read: “master”, for anything that is for the parent site, or not tenant-specific), one database per tenant, and any number of domains per tenant.

What is multi-tenancy?

These points hold true with single-tenant vs multi-tenancy in Laravel. If the application you are building does not call for the need of hosting multiple tenants, then a standard Laravel instance will provide you with everything you need. If your requirements dictate that you should be able to support the data of multiple companies with a single codebase, though, a multi-tenant instance makes sense. There are additional architectural considerations to be made for multi-tenancy. At this point, those considerations are left to the reader.

Before we get started

Pre-requisites

Ensure that your system packages are up-to-date:

sudo apt update && sudo apt upgrade

Other knowledge

sudo chown -R www-data:www-data /var/www
sudo find /var/www -type d -exec chmod 2750 {} \+
sudo find /var/www -type f -exec chmod 640 {} \+

The most popular package for multi-tenancy support for Laravel is available at https://github.com/tenancy/multi-tenant (formerly hyn/multi-tenant). This package provides a lot of great things out of the box: per-tenant configs, views, code and routes; multiple database separation methods; and event-driven architecture. What this package seems to lack, though, is a guide covering implementation from start to finish. Through my attempts at configuring multi-tenancy with Laravel, I had to patch the writings of multiple “be all and end all” guides to come to a fully working installation.

Getting Started

Creating an NGINX site config

Run nano /etc/nginx/sites-available/laravel to create our new configuration. We should populate this file with what I have listed below. Be sure to replace xyz.com with your domain!

server {
  listen 80 default_server;
  server_name xyz.com, *.xyz.com;  root /var/www/html/app/public;
  index index.php index.html index.htm index.nginx-debian.html;  location / {
    try_files $uri $uri/ /index.php?$query_string;
  }  location ~* \.php$ {
    fastcgi_pass unix:/run/php/php7.3-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
  }
}

After saving this file, create a symlink from sites-available to sites-enabled:

sudo ln -s /etc/nginx/sites-available/laravel /etc/nginx/sites-enabled/

Run NGINX’s configuration test to confirm that we didn’t break anything, and then restart NGINX:

sudo nginx -t
sudo service nginx restart

A couple of important things to call out:

  • Ensure that you replaced xyz.com with your domain!
  • Check out the following line:
  • server_name xyz.com, *.xyz.com;
  • This line is what will eventually allow us to catch traffic to xyz.com, as well as any subdomain of xyz.com like tenant1.xyz.com, apples.xyz.com, and so forth.

Creating our system database and user

During the installation of MariaDB, you were asked to set the root user’s password (note: this is the root MariaDB password, not the system’s root user’s /etc/passwd password). You will need that, so have it handy!

mysql -u root -p

The shell will ask for the root MariaDB password we mentioned, so enter it now.

CREATE DATABASE laravel;
CREATE USER laravel IDENTIFIED BY 'PasswordHere';
GRANT ALL PRIVILEGES ON *.* TO laravel@localhost IDENTIFIED BY 'PasswordHere' WITH GRANT OPTION;

At this point, we now have NGINX configured and our MariaDB system database and user created. Take note of the username and password that you used for the database user! Anywhere you see PasswordHere in this guide, replace it with the password you specified.

Add our user to the www-data group to allow for easier modification of files

sudo chown -R www-data:www-data /var/www
sudo find /var/www -type d -exec chmod 2750 {} \+
sudo find /var/www -type f -exec chmod 640 {} \+

Creating our Laravel application

cd /var/www/html/
composer create-project --prefer-dist laravel/laravel app

Great! Now we have a (sort of?) functional Laravel installation! If you navigate to your domain, you should see the Laravel welcome page.

Configuring Laravel

cd /var/www/html/app/
nano .env

We will need to change a few values here. Listed below are changes:

...
APP_URL=http://xyz.com
...
DB_CONNECTION=system
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=PasswordHere
...

Below are additions:

LIMIT_UUID_LENGTH_32=true
TENANT_URL_BASE=xyz.com
TENANCY_DATABASE_AUTO_DELETE=true
TENANCY_DATABASE_AUTO_DELETE_USER=true

Installing the multi-tenant package

composer require "hyn/multi-tenant" 5.4.*

That last line, composer require "hyn/multi-tenant" 5.4.*, tells composer that we need the multi-tenant package, but we only want versions greater than or equal to 5.4, and versions less than 5.5. Generally, when the developers of packages only change that last number in a release, nothing breaks, so we want to restrict to that range for safety.

Run the following commands to setup Laravel’s built-in auth routes / templates / migrations, as well as to publish the multi-tenancy configuration:

php artisan make:auth
php artisan vendor:publish --tag=tenancy

Modify the database configuration file at config/database.php by changing the key of the 'mysql' connection to 'system' (on around line 46, inside the array keyed 'connections').

Setting Up Multi-Tenancy

Configuration changes

EnforceTenancy Middleware

php artisan make:middleware EnforceTenancy

This will create a new file in app/Http/Middleware called EnforceTenancy.php. Modify it, adding the following to a new line right before return $next($request); in the handle function:

Config::set('database.default', 'tenant');

Additionally, add the following on a new line directly below use Closure;:

use Illuminate\Support\Facades\Config;

HTTP Kernel Modifications

'tenancy.enforce' => \App\Http\Middleware\EnforceTenancy::class,

Tenant Migrations

mkdir /var/www/html/app/database/migrations/tenant
mv /var/www/html/app/database/migrations/2014_10_12* /var/www/html/app/database/migrations/tenant/

You can now run the system migrations:

php artisan migrate

UsesTenantConnection Trait

Modify the User model at app/User.php by adding the following line directly below use Illuminate\Foundation\Auth\User as Authenticatable;:

use Hyn\Tenancy\Traits\UsesTenantConnection;

and replace the line use Notifiable; with the following:

use Notifiable, UsesTenantConnection;

UsesSystemConnection Trait

Making Changes

Creating a Tenant helper class

<?phpnamespace App;use Illuminate\Support\Facades\Artisan;
use Hyn\Tenancy\Environment;
use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Models\Website;
use Hyn\Tenancy\Contracts\Repositories\HostnameRepository;
use Hyn\Tenancy\Contracts\Repositories\WebsiteRepository;
use Illuminate\Support\Facades\Log;/**
 * @property Website website
 * @property Hostname hostname
 */class Tenant
{
    public function __construct(Website $website = null, Hostname $hostname = null)
    {        $this->website = $website ?? $sub->website;
        $this->hostname = $hostname ?? $sub->websites->hostnames->first();
    }    public function delete()
    {
        app(HostnameRepository::class)->delete($this->hostname, true);
        app(WebsiteRepository::class)->delete($this->website, true);
    }    public static function create($fqdn): Tenant
    {
        if (static::tenantExists($fqdn)) {
            $hostname = app(HostnameRepository::class)->findByHostname($fqdn);
            $website = $hostname->website;
            Log::info('Was asked to create tenant for FQDN ' . $fqdn . ' but a tenant with this FQDN already exists; returned existing tenant');
            return new Tenant($website, $hostname);
        }
        // Create New Website
        $website = new Website;
        app(WebsiteRepository::class)->create($website);        // associate the website with a hostname
        $hostname = new Hostname;
        $hostname->fqdn = $fqdn;
        // $hostname->force_https = true;
        app(HostnameRepository::class)->attach($hostname, $website);        // make hostname current
        app(Environment::class)->tenant($website);        return new Tenant($website, $hostname);
    }    public static function tenantExists($name)
    {
        return Hostname::where('fqdn', $name)->exists();
    }
}

This will allow for an easy interface to create new tenants.

Creating a MakeTenant command

To enable this, let’s create an artisan command that will allow us to make tenants!

php artisan make:command CreateTenant

Running this command will create a file at app/Console/Commands/CreateTenant.php. Let's modify that file.

Add the following line right below use Illuminate\Console\Command;:

use App\Tenant;

Change the signature of the command to:

protected $signature = 'tenancy:make-tenant {fqdn}';

and the description to:

protected $description = 'Create a new tenant';

Add the following to the handle() method:

$fqdn = $this->argument('fqdn') . '.' . env('TENANT_URL_BASE');
Tenant::create($fqdn);

This will allow us to create tenants using artisan like so:

php artisan tenancy:make-tenant test

…which would create the tenant test.xyz.com. Pretty cool, eh?

You can validate this by checking the hostnames table in your system database to see if test.xyz.com was created!

Changing routes

Because of this, it should not be possible for a user to log in or register from the main site (the site that doesn’t have a tenant associated with it). To make this happen, we need to set up our routes file and modify some templates!

Change your routes/web.php file to look like this:

<?phpRoute::domain('xyz.com')->group(function () {
  Route::get('/', function () {
    return view('welcome');
  });
  Route::any('{any}', function () {
    abort(404);
  })->where('any', '.*');
});Auth::routes();Route::get('/home', 'HomeController@index')->name('home');
Route::get('/', 'HomeController@index');

Fixing registration

Modify app/Http/Controllers/Auth/RegisterController.php and change the email address validation from:

'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],

to:

'email' => ['required', 'string', 'email', 'max:255', 'unique:tenant.users'],

Changing the layout file

Wrapping Up

What you can do from here is unlimited, and I have to stop somewhere. Go make something great :-)

Full code: https://github.com/alexleeelkins/Laravel-Multi-Tenant-Example

Sources of inspiration:

https://medium.com/faun/laravel-multi-tenant-app-setup-part-0-ee4c730f4c2a

Alex Elkins

Written by

@alexleeelkins / https://alexelkins.xyz — Software Consultant for Improving Enterprises — https://www.improving.com/