feat: Infrastructure multi-tenant avec isolation par sous-domaine

Une application SaaS éducative nécessite une séparation stricte des données
entre établissements scolaires. L'architecture multi-tenant par sous-domaine
(ecole-alpha.classeo.local) permet cette isolation tout en utilisant une
base de code unique.

Le choix d'une résolution basée sur les sous-domaines plutôt que sur des
headers ou tokens facilite le routage au niveau infrastructure (reverse proxy)
et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
This commit is contained in:
2026-01-30 23:34:10 +01:00
parent 6da5996340
commit 1fd256346a
71 changed files with 14390 additions and 37 deletions

0
backend/src/Controller/.gitignore vendored Normal file
View File

0
backend/src/Entity/.gitignore vendored Normal file
View File

0
backend/src/Repository/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Converts AccessDeniedException for TenantAware resources to 404 responses.
*
* CRITICAL SECURITY: This prevents information leakage about resource existence.
* When a user is denied access to a resource in another tenant, we return 404
* instead of 403 to avoid revealing that the resource exists.
*/
final readonly class TenantAccessDeniedHandler implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException', 2], // Higher priority than default
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if (!$exception instanceof AccessDeniedException) {
return;
}
// Check if this is a TenantAware resource denial
// The subject is stored in the exception's attributes
$subject = $exception->getSubject();
if ($subject instanceof TenantAwareInterface) {
// Convert to 404 to hide resource existence
$response = new JsonResponse(
[
'status' => Response::HTTP_NOT_FOUND,
'message' => 'Resource not found',
'type' => 'https://classeo.fr/errors/resource-not-found',
],
Response::HTTP_NOT_FOUND
);
$event->setResponse($response);
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Security;
use App\Shared\Infrastructure\Tenant\TenantId;
/**
* Interface for domain objects that belong to a specific tenant.
* Used by TenantVoter to enforce cross-tenant access control.
*/
interface TenantAwareInterface
{
public function getTenantId(): TenantId;
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Security;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantNotSetException;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Voter that enforces tenant isolation at the authorization level.
*
* CRITICAL SECURITY: This voter is the second line of defense (after TenantMiddleware).
* It prevents authenticated users from accessing resources belonging to other tenants.
*
* IMPORTANT: This voter ONLY handles the TENANT_ACCESS attribute to avoid interfering
* with other authorization checks (ROLE_*, EDIT, DELETE, etc.). Use isGranted('TENANT_ACCESS', $entity)
* explicitly when you need to verify tenant ownership.
*
* Returns ACCESS_DENIED (which is converted to 404 by AccessDeniedHandler) to avoid
* revealing the existence of resources in other tenants.
*
* @extends Voter<string, TenantAwareInterface>
*/
final class TenantVoter extends Voter
{
public const string ATTRIBUTE = 'TENANT_ACCESS';
public function __construct(
private readonly TenantContext $tenantContext,
) {
}
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
// Only vote on TENANT_ACCESS attribute to avoid bypassing other voters
return $attribute === self::ATTRIBUTE && $subject instanceof TenantAwareInterface;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
// User must be authenticated
if (!$user instanceof UserInterface) {
return false;
}
// Subject must be tenant-aware (should always be true due to supports())
if (!$subject instanceof TenantAwareInterface) {
return false;
}
// Tenant context must be set
try {
$currentTenantId = $this->tenantContext->getCurrentTenantId();
} catch (TenantNotSetException) {
// No tenant context - deny access
return false;
}
// CRITICAL: Verify resource belongs to current tenant
// If resource belongs to different tenant, return false (-> 404)
return $subject->getTenantId()->equals($currentTenantId);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant\Command;
use Doctrine\DBAL\DriverManager;
use Override;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'tenant:database:create',
description: 'Creates a new database for a tenant'
)]
final class CreateTenantDatabaseCommand extends Command
{
public function __construct(
private readonly string $masterDatabaseUrl,
) {
parent::__construct();
}
#[Override]
protected function configure(): void
{
$this
->addArgument('database_name', InputArgument::REQUIRED, 'The name of the database to create (e.g., classeo_tenant_alpha)')
->addArgument('database_user', InputArgument::OPTIONAL, 'The database user to grant access to', 'classeo')
;
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var string $databaseName */
$databaseName = $input->getArgument('database_name');
/** @var string $databaseUser */
$databaseUser = $input->getArgument('database_user');
// Validate database name format
if (!preg_match('/^classeo_tenant_[a-z0-9_]+$/', $databaseName)) {
$io->error('Database name must follow pattern: classeo_tenant_<name>');
return Command::FAILURE;
}
$io->title("Creating database: {$databaseName}");
try {
// Connect to master database (postgres) to create new database
$connection = DriverManager::getConnection(['url' => $this->masterDatabaseUrl]);
// Check if database already exists
$existsQuery = $connection->executeQuery(
'SELECT 1 FROM pg_database WHERE datname = :name',
['name' => $databaseName]
);
if ($existsQuery->fetchOne() !== false) {
$io->warning("Database '{$databaseName}' already exists.");
return Command::SUCCESS;
}
// Create database
// Note: Database names cannot be parameterized in SQL, so we use a validated name
$connection->executeStatement(sprintf(
'CREATE DATABASE %s WITH OWNER = %s ENCODING = \'UTF8\' LC_COLLATE = \'en_US.utf8\' LC_CTYPE = \'en_US.utf8\'',
$this->quoteIdentifier($databaseName),
$this->quoteIdentifier($databaseUser)
));
$io->success("Database '{$databaseName}' created successfully.");
return Command::SUCCESS;
} catch (Throwable $e) {
$io->error([
'Failed to create database',
$e->getMessage(),
]);
return Command::FAILURE;
}
}
private function quoteIdentifier(string $identifier): string
{
// Simple identifier quoting for PostgreSQL
return '"' . str_replace('"', '""', $identifier) . '"';
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant\Command;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function count;
use Override;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\Process;
use Throwable;
#[AsCommand(
name: 'tenant:migrate',
description: 'Run migrations for a specific tenant or all tenants'
)]
final class TenantMigrateCommand extends Command
{
public function __construct(
private readonly TenantRegistry $registry,
private readonly string $projectDir,
) {
parent::__construct();
}
#[Override]
protected function configure(): void
{
$this
->addArgument('subdomain', InputArgument::OPTIONAL, 'The subdomain of the tenant to migrate (or "all" for all tenants)')
->addOption('all', 'a', InputOption::VALUE_NONE, 'Run migrations for all tenants')
;
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var string|null $subdomain */
$subdomain = $input->getArgument('subdomain');
$all = $input->getOption('all');
if ($all || $subdomain === 'all') {
return $this->migrateAllTenants($io);
}
if ($subdomain === null) {
$io->error('Please provide a subdomain or use --all to migrate all tenants.');
return Command::FAILURE;
}
return $this->migrateTenant($subdomain, $io);
}
private function migrateTenant(string $subdomain, SymfonyStyle $io): int
{
try {
$config = $this->registry->getBySubdomain($subdomain);
return $this->runMigrationForConfig($config, $io);
} catch (Throwable $e) {
$io->error([
"Failed to migrate tenant '{$subdomain}'",
$e->getMessage(),
]);
return Command::FAILURE;
}
}
private function migrateAllTenants(SymfonyStyle $io): int
{
$io->title('Running migrations for all tenants');
$configs = $this->registry->getAllConfigs();
if ($configs === []) {
$io->warning('No tenants found in registry.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d tenant(s) to migrate.', count($configs)));
$failed = 0;
foreach ($configs as $config) {
$result = $this->runMigrationForConfig($config, $io);
if ($result !== Command::SUCCESS) {
++$failed;
}
}
if ($failed > 0) {
$io->error(sprintf('%d tenant(s) failed to migrate.', $failed));
return Command::FAILURE;
}
$io->success('All tenants migrated successfully.');
return Command::SUCCESS;
}
private function runMigrationForConfig(TenantConfig $config, SymfonyStyle $io): int
{
$io->section("Migrating tenant: {$config->subdomain}");
// Spawn a new process with DATABASE_URL set BEFORE the kernel boots.
// This ensures Doctrine uses the tenant's database connection.
$process = new Process(
command: ['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction'],
cwd: $this->projectDir,
env: [
...getenv(),
'DATABASE_URL' => $config->databaseUrl,
],
timeout: 300,
);
$process->run(static function (string $type, string $buffer) use ($io): void {
$io->write($buffer);
});
if ($process->isSuccessful()) {
$io->success("Tenant '{$config->subdomain}' migrated successfully.");
return Command::SUCCESS;
}
$io->error("Migration failed for tenant '{$config->subdomain}'");
return Command::FAILURE;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use Override;
/**
* In-memory implementation of TenantRegistry.
* Useful for tests and development environments.
*/
final class InMemoryTenantRegistry implements TenantRegistry
{
/** @var array<string, TenantConfig> Indexed by tenant ID */
private array $byId = [];
/** @var array<string, TenantConfig> Indexed by subdomain */
private array $bySubdomain = [];
/**
* @param TenantConfig[] $configs
*/
public function __construct(array $configs)
{
foreach ($configs as $config) {
$this->byId[(string) $config->tenantId] = $config;
$this->bySubdomain[$config->subdomain] = $config;
}
}
#[Override]
public function getConfig(TenantId $tenantId): TenantConfig
{
$key = (string) $tenantId;
if (!isset($this->byId[$key])) {
throw TenantNotFoundException::withId($tenantId);
}
return $this->byId[$key];
}
#[Override]
public function getBySubdomain(string $subdomain): TenantConfig
{
if (!isset($this->bySubdomain[$subdomain])) {
throw TenantNotFoundException::withSubdomain($subdomain);
}
return $this->bySubdomain[$subdomain];
}
#[Override]
public function exists(string $subdomain): bool
{
return isset($this->bySubdomain[$subdomain]);
}
#[Override]
public function getAllConfigs(): array
{
return array_values($this->byId);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
final readonly class TenantConfig
{
public function __construct(
public TenantId $tenantId,
public string $subdomain,
public string $databaseUrl,
) {
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
final class TenantContext
{
private ?TenantConfig $currentTenant = null;
public function setCurrentTenant(TenantConfig $config): void
{
$this->currentTenant = $config;
}
public function getCurrentTenantId(): TenantId
{
if ($this->currentTenant === null) {
throw new TenantNotSetException('No tenant is set in the current context.');
}
return $this->currentTenant->tenantId;
}
public function getCurrentTenantConfig(): TenantConfig
{
if ($this->currentTenant === null) {
throw new TenantNotSetException('No tenant is set in the current context.');
}
return $this->currentTenant;
}
public function hasTenant(): bool
{
return $this->currentTenant !== null;
}
public function clear(): void
{
$this->currentTenant = null;
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use App\Shared\Domain\Clock;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
final class TenantEntityManagerFactory
{
private const MAX_MANAGERS = 50;
private const IDLE_TIMEOUT_SECONDS = 300; // 5 minutes
/** @var array<string, array{manager: EntityManagerInterface, lastUsed: DateTimeImmutable}> */
private array $managers = [];
public function __construct(
private readonly TenantRegistry $registry,
private readonly Clock $clock,
private readonly Configuration $ormConfiguration,
) {
}
public function getForTenant(TenantId $tenantId): EntityManagerInterface
{
$key = (string) $tenantId;
// Evict idle connections first
$this->evictIdleConnections();
// Evict LRU if pool is full and we need a new manager
if (!isset($this->managers[$key]) && count($this->managers) >= self::MAX_MANAGERS) {
$this->evictLeastRecentlyUsed();
}
if (!isset($this->managers[$key])) {
$this->managers[$key] = [
'manager' => $this->createManagerForTenant($tenantId),
'lastUsed' => $this->clock->now(),
];
} else {
// Health check before returning cached manager (AC4 requirement)
$manager = $this->managers[$key]['manager'];
if (!$manager->isOpen() || !$manager->getConnection()->isConnected()) {
// Connection is dead, recreate the manager
$this->closeAndRemove($key);
$this->managers[$key] = [
'manager' => $this->createManagerForTenant($tenantId),
'lastUsed' => $this->clock->now(),
];
} else {
$this->managers[$key]['lastUsed'] = $this->clock->now();
}
}
return $this->managers[$key]['manager'];
}
public function getPoolSize(): int
{
return count($this->managers);
}
private function evictLeastRecentlyUsed(): void
{
if (empty($this->managers)) {
return;
}
$oldestKey = null;
$oldestTime = null;
foreach ($this->managers as $key => $data) {
if ($oldestTime === null || $data['lastUsed'] < $oldestTime) {
$oldestKey = $key;
$oldestTime = $data['lastUsed'];
}
}
if ($oldestKey !== null) {
$this->closeAndRemove($oldestKey);
}
}
private function evictIdleConnections(): void
{
$now = $this->clock->now();
$keysToRemove = [];
foreach ($this->managers as $key => $data) {
$idleSeconds = $now->getTimestamp() - $data['lastUsed']->getTimestamp();
if ($idleSeconds > self::IDLE_TIMEOUT_SECONDS) {
$keysToRemove[] = $key;
}
}
foreach ($keysToRemove as $key) {
$this->closeAndRemove($key);
}
}
private function closeAndRemove(string $key): void
{
if (isset($this->managers[$key])) {
$manager = $this->managers[$key]['manager'];
if ($manager->isOpen()) {
$manager->close();
}
unset($this->managers[$key]);
}
}
private function createManagerForTenant(TenantId $tenantId): EntityManagerInterface
{
$config = $this->registry->getConfig($tenantId);
// Parse database URL and create connection parameters
$connectionParams = $this->parseConnectionParams($config->databaseUrl);
// Health check before creation
/** @phpstan-ignore argument.type (Doctrine accepts both URL and explicit params) */
$connection = DriverManager::getConnection($connectionParams);
$this->healthCheck($connection);
return new EntityManager($connection, $this->ormConfiguration);
}
/**
* @return array<string, mixed>
*/
private function parseConnectionParams(string $databaseUrl): array
{
// Handle SQLite in-memory specially
if (str_starts_with($databaseUrl, 'sqlite:///:memory:')) {
return [
'driver' => 'pdo_sqlite',
'memory' => true,
];
}
// For other databases, use URL parameter
return ['url' => $databaseUrl];
}
private function healthCheck(\Doctrine\DBAL\Connection $connection): void
{
// Verify the database is accessible by executing a simple query
// This implicitly connects and validates the connection
$connection->executeQuery('SELECT 1');
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use App\Shared\Domain\EntityId;
final readonly class TenantId extends EntityId
{
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final readonly class TenantMiddleware implements EventSubscriberInterface
{
public function __construct(
private TenantResolver $resolver,
private TenantContext $context,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 256], // High priority - run early
KernelEvents::TERMINATE => 'onKernelTerminate',
];
}
private const array PUBLIC_PATHS = [
'/api/docs',
'/api/docs.json',
'/api/docs.jsonld',
'/api/contexts',
'/_profiler',
'/_wdt',
'/_error',
];
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$path = $request->getPathInfo();
// Skip tenant resolution for public paths (docs, profiler, etc.)
foreach (self::PUBLIC_PATHS as $publicPath) {
if (str_starts_with($path, $publicPath)) {
return;
}
}
$host = $request->getHost();
try {
$config = $this->resolver->resolve($host);
$this->context->setCurrentTenant($config);
// Store tenant config in request for easy access
$request->attributes->set('_tenant', $config);
} catch (TenantNotFoundException) {
// Return 404 with generic message - DO NOT reveal tenant existence
$response = new JsonResponse(
[
'status' => Response::HTTP_NOT_FOUND,
'message' => 'Resource not found',
'type' => 'https://classeo.fr/errors/resource-not-found',
],
Response::HTTP_NOT_FOUND
);
$event->setResponse($response);
}
}
public function onKernelTerminate(): void
{
$this->context->clear();
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use RuntimeException;
use function sprintf;
final class TenantNotFoundException extends RuntimeException
{
public static function withSubdomain(string $subdomain): self
{
return new self(sprintf('Tenant with subdomain "%s" not found.', $subdomain));
}
public static function withId(TenantId $tenantId): self
{
return new self(sprintf('Tenant with ID "%s" not found.', $tenantId));
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use LogicException;
final class TenantNotSetException extends LogicException
{
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
interface TenantRegistry
{
/**
* Retrieves the tenant configuration for the given tenant ID.
*
* @throws TenantNotFoundException if tenant does not exist
*/
public function getConfig(TenantId $tenantId): TenantConfig;
/**
* Retrieves the tenant configuration for the given subdomain.
*
* @throws TenantNotFoundException if tenant does not exist
*/
public function getBySubdomain(string $subdomain): TenantConfig;
/**
* Checks if a tenant with the given subdomain exists.
*/
public function exists(string $subdomain): bool;
/**
* Retrieves all tenant configurations.
*
* @return TenantConfig[]
*/
public function getAllConfigs(): array;
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use InvalidArgumentException;
use function is_array;
use const JSON_THROW_ON_ERROR;
/**
* Factory to create TenantRegistry from configuration arrays.
* Used for dev/test (YAML config) and prod (env var JSON).
*/
final readonly class TenantRegistryFactory
{
/**
* Creates registry from YAML configuration array (dev/test).
*
* @param array<array{tenantId: string, subdomain: string, databaseUrl: string}> $configs
*/
public function createFromConfig(array $configs): TenantRegistry
{
return new InMemoryTenantRegistry($this->parseConfigs($configs));
}
/**
* Creates registry from JSON environment variable (prod).
*
* Expected format: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}]
*/
public function createFromEnv(string $configsJson): TenantRegistry
{
if ($configsJson === '') {
return new InMemoryTenantRegistry([]);
}
$decoded = json_decode($configsJson, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new InvalidArgumentException('TENANT_CONFIGS must be a JSON array');
}
/** @var array<array{tenantId: string, subdomain: string, databaseUrl: string}> $configs */
$configs = $decoded;
return new InMemoryTenantRegistry($this->parseConfigs($configs));
}
/**
* @param array<array{tenantId: string, subdomain: string, databaseUrl: string}> $configs
*
* @return TenantConfig[]
*/
private function parseConfigs(array $configs): array
{
$tenantConfigs = [];
foreach ($configs as $config) {
$tenantConfigs[] = new TenantConfig(
tenantId: TenantId::fromString($config['tenantId']),
subdomain: $config['subdomain'],
databaseUrl: $config['databaseUrl'],
);
}
return $tenantConfigs;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use function in_array;
use function strlen;
readonly class TenantResolver
{
private const array RESERVED_SUBDOMAINS = ['www', 'api', 'admin', 'static', 'cdn', 'mail'];
public function __construct(
private TenantRegistry $registry,
private string $baseDomain,
) {
}
/**
* Resolves a tenant from a host header value.
*
* @throws TenantNotFoundException if tenant cannot be resolved
*/
public function resolve(string $host): TenantConfig
{
$subdomain = $this->extractSubdomain($host);
if ($subdomain === null) {
throw TenantNotFoundException::withSubdomain('');
}
return $this->registry->getBySubdomain($subdomain);
}
/**
* Extracts the subdomain from a host header.
* Returns null if no tenant subdomain is present (main domain or reserved subdomain).
*/
public function extractSubdomain(string $host): ?string
{
// Remove port if present
$host = explode(':', $host)[0];
// Check if host ends with base domain
$baseDomain = '.' . $this->baseDomain;
if (!str_ends_with($host, $baseDomain)) {
// Host doesn't match our domain - could be the base domain itself
if ($host === $this->baseDomain) {
return null;
}
return null;
}
// Extract subdomain
$subdomain = substr($host, 0, -strlen($baseDomain));
// Empty subdomain or reserved
if ($subdomain === '' || in_array($subdomain, self::RESERVED_SUBDOMAINS, true)) {
return null;
}
return $subdomain;
}
}