Adding Tenants and Verifying Separation

This article is a continuation of Creating a Laravel SaaS Framework .
Part 1, Part 2, Part 3

Picking up from the last article, we now have a single tenant on our system with the subdomain of “foo”. Let’s work on a few additions to the app, including:

  • A second tenant!
  • Abort if a tenant doesn’t exist

Adding a Second Tenant

Let’s add a second tenant and verify that they have some separation. For this one, we’ll use a subdomain of “bar”. Make sure to save the password somewhere.

$ php artisan tenant:create bar "Tabitha Bojangles" #ob#gnovgun#at#rknzcyr.pbz#ob#

Tenant 'Tabitha Bojangles' created for bar.localhost
The user '#ob#gnovgun#at#rknzcyr.pbz#ob#' can log in with password evfyZmdan0HyVcse

Now let’s check our database to make sure that everything went according to plan. We now see a second hostname set up in the tenancy database as well as a website and a new database matching the UUID.

Verifying Tenant Separation

At this point it’s important to verify that we can sign in to both tenant sites and that one tenant does not have access to the other tenant. Since we already added our hostnames to Windows hosts, we should be able to navigate to both foo.localhost:8000 and bar.localhost:8000 (make sure you’re still running the server with the artisan serve command).

Navigate to foo.localhost:8000 first. It should display the default Laravel page with login and register at the top. Click LOGIN and enter Billy Bob’s email and password (created in Part 2). You should successfully show the logged in /home page.

Now open up a new browser tab and navigate to bar.localhost:8000. If everything went well, you should NOT be logged in here. Also, if you navigate to bar.localhost:8000/home it should automatically direct you to the correct bar.localhost:8000/login page. Let’s log in to the “bar” tenant just to see if that’s working. Using the credentials you hopefully saved earlier in this walk-through, log in as Tabitha.

So now you should be logged in as both users. Navigating back and forth between foo.localhost:8000 and bar.localhost:8000 will should show their respective pages.

Abort if a Tenant Doesn’t Exist

What happens now if we navigate to something that doesn’t exist? For example, unicorns.localhost:8000. Well, at this point you should still see the default Laravel page, with Login and Register links a the top. That’s not ideal, because if you click on that Login link and enter any credentials you’ll get that “Database [tenant] not configured” error.

Unicorns not configured

To deal with this problem, there are a couple of options.

Option 1: Use the Hyn Tenancy options

The Hyn Tenancy package comes with an option to abort if the hostname isn’t found. There’s an option for TENANCY_AUTO_HOSTNAME_IDENTIFICATION and TENANCY_ABORT_WITHOUT_HOSTNAME that work together with the HostnameActions middleware to display a 404 if the hostname can’t be identified.

Simply add the following to your .env file to enable this. The auto identification option is already defaulted to true, so we don’t need to worry about that.

.env
TENANCY_ABORT_WITHOUT_HOSTNAME=true

Refresh your cache in the console for this to take effect.

$ php artisan config:cache

Configuration cache cleared!
Configuration cached successfully!

Now if we navigate to unicorns.localhost:8000 we get a 404 page.

Laravel 404

That’s pretty good, but if we navigate to localhost:8000 with NO subdomain, we’re also going to get that 404. That’s not ideal since we’re going to want to add on some sign up forms and such so that people can use (and pay for) our application.

Option 2: Roll Our Own Middleware

If you tried option 1, make sure you remove that TENANCY_ABORT_WITHOUT_HOSTNAME=true line from your .env and clear your cache again.

Instead of using the built-in middleware, let’s create our own so that we have more control over where the 404 page shows up. Start by creating the middleware with an artisan command.

$ php artisan make:middleware TenantExists

Middleware created successfully.

Open up the newly created TenantExists middleware file and modify it like follows:

app\Http\Middleware\TenantExists.php
<?php

namespace App\Http\Middleware;

use Hyn\Tenancy\Models\Hostname;
use Closure;

class TenantExists
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ( $request->user() == null ) {
            $fqdn = $request->getHost();
            if ( ! $this->tenantExists( $fqdn ) ) {
                abort(403,'Nope.');
            }
        }
        
        return $next($request);
    }

    private function tenantExists( $fqdn ) {
        return Hostname::where( 'fqdn', $fqdn )->exists();
    }
}

Here we’re not actually returning a 404. Instead we’re returning a 403 with a message of “Nope.”

Now let’s add our new middleware to the Kernel so that we can use it in our routes. Find the $routeMiddleware array and add in our new class.

app\Http\Kernel.php
    /**
     * The application's route middleware.
     *
     * These middleware may be assigned to groups or used individually.
     *
     * @var array
     */
    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
        'tenancy.enforce' => \App\Http\Middleware\EnforceTenancy::class,
        'tenant.exists' => \App\Http\Middleware\TenantExists::class,
    ];

We’re all set to start using this in our routes, but we’ll need to break out or Auth routes in order to add them to a new middleware group. Open up web.php and replace Auth::routes() with all of the individual authentication routes, wrapped in our middleware. This is outside our domain route for localhost, which will handle the root domain.

routes\web.php
...

// Ensure that the tenant exists with the tenant.exists middleware
Route::middleware('tenant.exists')->group(function () {
    // Not Logged In
    Route::get('/', function () {
        return view('welcome');
    });

    // Login Routes
    Route::get('login', 'Auth\LoginController@showLoginForm')->name('login');
    Route::post('login', 'Auth\LoginController@login');
    Route::post('logout', 'Auth\LoginController@logout')->name('logout');
    
    // Password Reset Routes
    Route::get('password/reset', 'Auth\ForgotPasswordController@showLinkRequestForm')->name('password.request');
    Route::post('password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail')->name('password.email');
    Route::get('password/reset/{token}', 'Auth\ResetPasswordController@showResetForm')->name('password.reset');
    Route::post('password/reset', 'Auth\ResetPasswordController@reset')->name('password.update');

    // Email Verification Routes
    Route::get('email/verify', 'Auth\VerificationController@show')->name('verification.notice');
    Route::get('email/verify/{id}', 'Auth\VerificationController@verify')->name('verification.verify');
    Route::get('email/resend', 'Auth\VerificationController@resend')->name('verification.resend');

    // Register Routes
    Route::get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
    Route::post('register', 'Auth\RegisterController@register');
});

// Logged in
Route::get('/home', 'HomeController@index')->name('home');

Now any top level urls or login routes will first to through the new middleware. Let’s navigate to localhost:8000 to make sure we’re seeing the page like we should.

Laravel default page

Perfect! Now let’s navigate to unicorns.localhost:8000 to see what we get.

Laravel 403 | Nope.

Just what we were looking for.

Wrapping Up

We now have two separate tenants and the ability to separate out a landing page on the top level domain for new users to register. In the next article I’ll cover the registration process and how it will set up the tenants. Depending on how long that piece gets, I may get to installing Laravel Cashier as well so that we can see how that will tie in.

Additional Resources: