Multi-tenant

apps without fuss

 

0. laravel new

1. Config

// config/tenants.php return [ 'nl' => [ 'slug' => 'nl', 'default' => true, 'domain' => array_filter([ env('NL_DOMAIN'), 'nl.example.com', 'nl-staging.example.com', 'sports-nl.test', ]), ], 'gr' => [ 'slug' => 'gr', 'default' => false, 'domain' => array_filter([ env('GR_DOMAIN'), 'gr.example.com', 'gr-staging.example.com', 'sports-gr.test', ]), ], 'pt' => [ 'slug' => 'pt', 'default' => false, 'domain' => array_filter([ env('PT_DOMAIN'), 'pt.example.com', 'pt-staging.example.com', 'sports-pt.test', ]), ], ];

2. Resolving

public function resolveHttp(): stdClass { $domain = request()->getHost(); $tenants = config('tenants'); foreach ($tenants as $tenant) { if (str($tenant['domain'])->is($domain)) { return to_object($tenant); } } return $this->defaultTenant(); }
public function resolveCli(): stdClass { $slug = env('TENANT'); $tenants = config('tenants'); foreach ($tenants as $tenant) { if ($tenant['slug'] === $slug) { return to_object($tenant); } } return $this->defaultTenant(); }
TENANT=nl php artisan migrate
public function defaultTenant(): stdClass { $tenants = config('tenants'); foreach ($tenants as $tenant) { if ($tenant['default'] ?? false) { return to_object($tenant); } } throw new Exception('tenant not found'); }
namespace App\Bootstrap; use Illuminate\Contracts\Foundation\Application; class ResolveTenantViaHttpRequest { public function bootstrap(Application $app): void { $app->singleton( 'tenant', fn () => (new TenantResolver())->resolve(TenantResolver::$HTTP) ); } }
namespace App\Http; class Kernel extends HttpKernel { protected $bootstrappers = [ ResolveTenantViaHttpRequest::class, // ...copy rest from parent ]; }

3. Helpers

if (! function_exists('tenant')) { function tenant(string $key = null): mixed { $resolved = resolve('tenant'); if (! $key) { return $resolved; } return $resolved->$key; } }
"autoload": { "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" }, "files": [ "app/helpers.php" ] },
composer du -o

4. More config

 
namespace App\Bootstrap; use Exception; use Illuminate\Config\Repository; use Illuminate\Contracts\Config\Repository as RepositoryContract; use Illuminate\Contracts\Foundation\Application; use Illuminate\Foundation\Bootstrap\LoadConfiguration; class LoadTenantConfiguration extends LoadConfiguration { public function bootstrap(Application $app): void { parent::bootstrap($app); $tenant = tenant('slug'); $this->loadTenantConfiguration($app, $tenant); } private function loadTenantConfiguration(Application $app, string $tenant): void { $this->setCachingDirectory($tenant); if ($this->loadedFromCache($app)) { return; } $env = $app->environment(); $overrideConfigPaths = [ fmt('overrides.env.%', $env), fmt('overrides.tenant.%', $tenant), fmt('overrides.tenant-env.%-%', $tenant, $env), ]; $config = $app->get(RepositoryContract::class); foreach ($overrideConfigPaths as $overrideConfigPath) { $overrideConfig = $config->get($overrideConfigPath); if (! $overrideConfig) { continue; } foreach ($overrideConfig as $configKey => $configValues) { $config->set( $configKey, array_replace_recursive( $config->get($configKey), $configValues ) ); } } $config->set('overrides', null); } private function setCachingDirectory(string $tenant): void { putenv(fmt('APP_ROUTES_CACHE=bootstrap/cache/%-routes.php', $tenant)); putenv(fmt('APP_EVENTS_CACHE=bootstrap/cache/%-events.php', $tenant)); putenv(fmt('APP_CONFIG_CACHE=bootstrap/cache/%-config.php', $tenant)); } private function loadedFromCache(Application $app): bool { $items = []; $loadedFromCache = false; if (is_file($cached = $app->getCachedConfigPath())) { $items = include $cached; $loadedFromCache = true; $app->instance('config', new Repository($items)); } return $loadedFromCache; } }
// config/overrides/tenant/nl/app.php return [ 'url' => env('NL_APP_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('NL_AWS_ASSETS_URL'), 'tenant_timezone' => 'Europe/Amsterdam', 'name' => 'nl', ];

5. Views

 
namespace App\Providers; use Illuminate\Support\ServiceProvider; class ViewServiceProvider extends ServiceProvider { public function boot() { $this->loadViewsFrom( [ resource_path(fmt('views/theme/%', tenant('slug'))), resource_path('views'), ], 'theme' ); } }
view('theme::contacts/list') // resources/views/theme/acme/contacts/list.blade.php → if exists // resources/views/contacts/list.blade.php → fallback

Works with components!

namespace App\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; class ViewServiceProvider extends ServiceProvider { public function boot() { Blade::anonymousComponentPath(resource_path(fmt('views/theme/%', tenant('slug'))), 'theme'); Blade::anonymousComponentPath(resource_path('views/components'), 'theme'); } }
<theme::x-layout /> // resources/views/theme/acme/components/layout.blade.php → if exists // resources/views/components/layout.blade.php → fallback

6. Vite

APP_HOSTNAME=sports-nl.test APP_URL="https://${APP_HOSTNAME}"
npm i dotenv
// vite.config.js import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import dotenv from 'dotenv'; import path from 'path'; dotenv.config({ path: path.join(__dirname, '.env') }); let { APP_HOSTNAME } = process.env; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, detectTls: APP_HOSTNAME, }), ], });
// vite.config.js import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import dotenv from 'dotenv'; import path from 'path'; dotenv.config({ path: path.join(__dirname, '.env') }); let { APP_HOSTNAME, TENANT } = process.env; if (!TENANT) { TENANT = 'nl'; } export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, detectTls: APP_HOSTNAME, buildDirectory: path.join('theme', TENANT), }), ], });
@vite(['resources/css/app.css'], 'theme/'.tenant('slug'))

7. Tailwind

// tailwind.config.js const dotenv = require('dotenv'); const path = require('path'); dotenv.config({ path: path.join(__dirname, '.env') }); let { TENANT } = process.env; if (!TENANT) { TENANT = 'nl'; } module.exports = require(path.join(__dirname, `tailwind.config.${TENANT}.js`));
 

8. Components

<!-- resources/views/components/layout.blade.php --> <!doctype html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ config('app.name') }}</title> @livewireStyles @vite('resources/css/app.css') </head> <body> <div class="flex flex-col min-h-screen w-full"> <div class="flex flex-col flex-shrink w-full items-end"> <x-menus.primary /> <x-menus.secondary /> <x-menus.tertiary /> </div> <div class="flex flex-col flex-grow w-full"> {{ $slot }} </div> <div class="flex flex-col flex-shrink w-full"> <x-menus.social /> <x-menus.footer /> </div> </div> @livewireScriptConfig @vite('resources/js/app.js') @stack('scripts') </body> </html>
<!-- resources/views/components/layout.blade.php --> @props([ 'title' => null, ]) <!doctype html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ $title ?? config('app.name') }}</title> @livewireStyles @vite('resources/css/app.css') </head>
<!-- resources/views/components/layout.blade.php --> @props([ 'title' => null, 'meta' => null, ]) <!doctype html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ $title ?? config('app.name') }}</title> {{ $meta ?? '' }} @livewireStyles @vite('resources/css/app.css') </head>
<!-- resources/views/pages/home.blade.php --> <x-layout title="this will be $title"> This will be $slot <x-slot:meta> This will be $meta </x-slot> </x-layout>
@once @push('scripts') <script> // some js code </script> @endpush @endonce

9. Context

// ...in one file context()->add('tenant', 'nl'); // ...in another file context()->get('tenant');
// app/Models/Page.php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Page extends Model { protected static function booted() { static::saving(function(Page $page) { $page->tenant_id = context()->get('tenant')->id; }); } }
// app/Models/Page.php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Page extends Model { protected static function booted() { $id = context()->get('tenant')->id; static::saving(function(Page $page) use ($id) { $page->tenant_id = $id; }); static::addGlobalScope('tenant', function (Builder $builder) use ($id) { $builder->where('tenant_id', '=', $id); }); } }

10. Wisdom

Use a separate database per tenant

You can share cache, but use prefix

Avoid conditionals in templates,

prefer component/view replacement

Use Herd

Back to assertchris.io →