Multi-tenant
apps without fuss
// 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',
]),
],
];
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
];
}
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"
]
},
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',
];
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
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
APP_HOSTNAME=sports-nl.test
APP_URL="https://${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 } = 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'))
// 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`));
<!-- 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
// ...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);
});
}
}
Use a separate database per tenant
You can share cache, but use prefix
Avoid conditionals in templates,
prefer component/view replacement