Hello

Featherweight

Multi-tenancy

in Laravel

Bootstrappers

class Kernel extends HttpKernel
{
    protected $middleware = [
        // ...
    ];

    protected $middlewareGroups = [
        // ...
    ];

    protected $middlewareAliases = [
        // ...
    ];

    public function bootstrappers(): array
    {
        return [
            ...$this->bootstrappers,
            BootstrapTenant::class,
            BootstrapTenantConfiguration::class,
        ];
    }
}
class Kernel extends ConsoleKernel
{
    public function bootstrappers(): array
    {
        return [
            ...$this->bootstrappers,
            BootstrapTenant::class,
            BootstrapTenantConfiguration::class,
        ];
    }

    protected function schedule(Schedule $schedule): void
    {
        // $schedule->command('inspire')->hourly();
    }

    protected function commands(): void
    {
        $this->load(__DIR__ . '/Commands');
    }
}
public function bootstrap(Application $app): void
{
    $this->registerDomainOption();

    if (app()->runningInConsole()) {
        $domain = (new ArgvInput())
            ->getParameterOption('--tenant-domain');
    } else {
        $domain = request()->getHost();
    }

    $app->singleton(
        'tenant',
        fn () => resolve(ResolveTenant::class)($domain)
    );
}
private function registerDomainOption(): void
{
    app('events')->listen(ArtisanStarting::class, function ($event) {
        $definition = $event->artisan->getDefinition();

        $definition->addOption(
            new InputOption(
                name: '--tenant-domain',
                shortcut: '-td',
                mode: InputOption::VALUE_OPTIONAL,
                description: '...',
            )
        );

        $event->artisan->setDefinition($definition);
        $event->artisan->setDispatcher(app(EventDispatcher::class));
    });
}
public function __invoke(string $domain): object
{
    $resolved = $this->resolveDomain($domain);

    if ($resolved) {
        return $resolved;
    }

    $resolved = $this->resolveDefault();

    if ($resolved) {
        return $resolved;
    }

    throw new InvalidArgumentException('bad tenant');
}
private function resolveDomain(string $domain): ?object
{
    foreach (config('tenants') as $key => $tenant) {
        if (Str::is($tenant['domains'], $domain)) {
            return (object) [
                ...$tenant,
                'key' => $key,
            ];
        }
    }

    return null;
}
return [
    'micromarket' => [
        'domains' => [
            'micromarket.co',
            'micromarket.test',
        ],
        'default' => true,
    ],
    // ...
    'retroconsole' => [
        'domains' => [
            'retroconsole.co',
            'retroconsole.test',
        ],
        'default' => false,
    ],
];

Config

use Illuminate\Config\Repository;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\Bootstrap\LoadConfiguration;

class BootstrapTenantConfiguration extends LoadConfiguration
{
    public function bootstrap(Application $app)
    {
        parent::bootstrap($app);
        
        $tenant = tenant('key');

        $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('config');

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)
{
    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
{
    if (is_file($cached = $app->getCachedConfigPath())) {
        $app->instance('config', new Repository(include $cached));

        return true;
    }

    return false;
}
config
├── app.php
├── auth.php
├── ...
├── mail.php
├── overrides
│   └── tenant
│       ├── ...
│       ├── micromarket
│       │   ├── database.php
│       │   └── view.php
│       └── retroconsole
│           ├── database.php
│           └── view.php
├── queue.php
├── ...
├── tenants.php
└── view.php

Views

return [
    'paths' => [
        resource_path('views/overrides/retroconsole'),
        resource_path('views'),
    ],
];
resources/views
├── components
│   └── layouts
│       ├── app.blade.php
│       └── base.blade.php
├── home.blade.php
├── livewire
│   └── navigation
│       └── header.blade.php
└── overrides
    ├── ...
    ├── micromarket
    │   └── components
    │       └── logo.blade.php
    └── retroconsole
        └── components
            └── logo.blade.php

Themes

const dotenv = require('dotenv');
const path = require('path');

dotenv.config({ path: path.join(__dirname, '.env') });

let { TENANT } = process.env;

if (!TENANT) {
    // throw new Error('unknown tenant');

    // the GitHub action does not make it easy to infer a default tenant
    // and I don't want to parse or defer to the PHP configuration,
    // so we hard-code the default

    TENANT = 'micromarket';
}

module.exports = require(path.join(__dirname, `tailwind.config.${TENANT}.js`));
const defaultTheme = require('tailwindcss/defaultTheme');

module.exports = {
    mode: 'jit',

    content: [
        './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
        './storage/framework/views/*.php',
        './resources/**/*.blade.php',
        './resources/**/*.svg',
    ],

    theme: {
        extend: {
            // ...
        },
    },
};
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 { TENANT, VITE_HOST } = process.env;

VITE_HOST = VITE_HOST || 'localhost';

if (!TENANT) {
    // throw new Error('unknown tenant');

    // the GitHub action does not make it easy to infer a default tenant
    // and I don't want to parse or defer to the PHP configuration,
    // so we hard-code the default

    TENANT = 'micromarket';
}

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            buildDirectory: path.join('theme', TENANT),
        }),
    ],
});
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="{{ csrf_token() }}">

        <title>{{ config('app.name') }}</title>

        <livewire:styles />
        <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
        @vite(['resources/css/app.css'], fmt('theme/%', tenant('key')))
    </head>
    <body>
        {{ $slot }}
        <livewire:scripts />
        @vite(['resources/js/app.js'], fmt('theme/%', tenant('key')))
    </body>
</html>

Multiple Databases

return [
    'connections' => [
        'mysql' => [
            'url' => env('RETRO_CONSOLE_DB_URL'),
            'host' => env('RETRO_CONSOLE_DB_HOST', '127.0.0.1'),
            'port' => env('RETRO_CONSOLE_DB_PORT', '3306'),
            'database' => env('RETRO_CONSOLE_DB_DATABASE', 'forge'),
            'username' => env('RETRO_CONSOLE_DB_USERNAME', 'forge'),
            'password' => env('RETRO_CONSOLE_DB_PASSWORD', ''),
            'unix_socket' => env('RETRO_CONSOLE_DB_SOCKET', ''),
        ],
    ],
];
# ...

MICRO_MARKET_DB_CONNECTION=mysql
MICRO_MARKET_DB_HOST=127.0.0.1
MICRO_MARKET_DB_PORT=3306
MICRO_MARKET_DB_DATABASE=micromarket
MICRO_MARKET_DB_USERNAME=root
MICRO_MARKET_DB_PASSWORD=root

RETRO_CONSOLE_DB_CONNECTION=mysql
RETRO_CONSOLE_DB_HOST=127.0.0.1
RETRO_CONSOLE_DB_PORT=3306
RETRO_CONSOLE_DB_DATABASE=retroconsole
RETRO_CONSOLE_DB_USERNAME=root
RETRO_CONSOLE_DB_PASSWORD=root

TENANT=retroconsole

Shared Database

class Listing extends Model
{
    use Tenanted;
 
    protected $fillable = [
        'title',
        'tenant_key',
        // ...
    ];
}
trait Tenanted
{
    protected static function bootTenanted(): void
    {
        $key = tenant('key');
        
        static::creating(function ($model) use ($key) {
            $model->tenant_key = $key;
        });
        
        if (!auth()->user()->is_admin) {
          static::addGlobalScope('tenanted', function (Builder $builder) use ($key) {
              $builder->where('tenant_key', $key);
          });
        }
    }
}

Where to next?

Better data properties

Better search properties

questions?

twitter.com/assertchris

slides.com/assertchris/featherweight-multi-tenancy-july-2023

Featherweight Multi-tenancy (July 2023)

By Christopher Pitt

Featherweight Multi-tenancy (July 2023)

  • 189