Christopher Pitt
Writer and coder, working at ringier.co.za
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,
],
];
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
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
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>
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
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);
});
}
}
}
slides.com/assertchris/featherweight-multi-tenancy-july-2023
By Christopher Pitt