phpsoftbox / multi-tenant
Multi-tenant extension for PhpSoftBox
Requires
- php: ^8.5
- ext-pdo: *
- phpsoftbox/auth: ^1.0
- phpsoftbox/broadcaster: ^1.1
- phpsoftbox/cache: ^1.0
- phpsoftbox/cli-app: ^1.0
- phpsoftbox/clock: ^1.0
- phpsoftbox/config: ^1.0
- phpsoftbox/database: ^1.0
- phpsoftbox/inflector: ^1.0
- phpsoftbox/orm: ^1.0
- phpsoftbox/profiler: ^1.0
- phpsoftbox/queue: ^1.0
- phpsoftbox/storage: ^1.0
- phpsoftbox/telegram: ^1.0
- psr/container: ^2.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
- ramsey/uuid: ^4.7
Requires (Dev)
- phpsoftbox/cs-fixer: ^1.2.2
- phpsoftbox/http-message: ^1.0
- phpsoftbox/mongo: ^1.0
- phpunit/phpunit: ^13.3
Suggests
- phpsoftbox/mongo: Required only for tenant:mongo:* commands and MongoDB-based tenant features.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-30 11:22:24 UTC
README
phpsoftbox/multi-tenant — расширение для multi-tenant сценариев:
- выбор tenant в CLI (
--tenant, по умолчаниюall) - tenant-aware миграции (
tenant:db:migrate,tenant:db:rollback) - provisioning tenant БД из template (
tenant:db:provision) - tenant-aware запуск Pushr (
tenant:pushr:serve,tenant:pushr:serve:registry) по tenant-приложениям - два провайдера tenant-реестра:
ConfigTenantProviderDatabaseTenantProvider(core БД, JSON payloaddataчерез ORM typecaster)
Рекомендуемая production-схема: DatabaseTenantProvider как primary, ConfigTenantProvider использовать только как fallback/parity-check.
Команды
-
tenant:list -
tenant:config:check [--primary=database|config] [--connection=default] -
tenant:db:migrate [--tenant=all] [--path=...] [--fail-fast] -
tenant:db:rollback [--tenant=all] [--path=...] [--steps=1] [--fail-fast] -
tenant:db:provision --tenant=<id>|all [--template=<id>] [--migrations-table=migrations] [--drop-existing] [--fail-fast] -
tenant:provision:run --tenant=<id>|all [--template=<id>] [--owner-phone=...] [--owner-name=...] [--owner-email=...] [--no-confirm-phone] -
tenant:provision:dispatch --tenant=<id>|all [--template=<id>] [--owner-*=...] [--no-confirm-phone] [--priority=0] -
tenant:pushr:serve [--tenant=all] [--host=0.0.0.0] [--port=8080] [--max-skew=300] [--ping-interval=25] [--idle-timeout=60] -
tenant:pushr:serve:registry [--tenant=all] [--host=0.0.0.0] [--port=8080] [--max-skew=300] [--ping-interval=25] [--idle-timeout=60] [--without-default-app]Keepalive сервера (
phpsoftbox/broadcaster^1.1): ping простаивающим соединениям через--ping-intervalсекунд, закрытие молчащих дольше--idle-timeout;0отключает проверку,--idle-timeoutбольше--ping-interval. -
tenant:queue:core:run [--max-jobs=0] -
tenant:queue:tenant:run [--tenant=all] [--max-jobs=0] -
tenant:auth:role:sync [--scope=all|core|tenant] [--tenant=all] [--dry-run] -
tenant:auth:role:assign <user> <role> [--scope=tenant] [--tenant=<id>|all] -
tenant:auth:role:discharge <user> <role> [--scope=tenant] [--tenant=<id>|all] -
tenant:telegram:poll [--tenant=all] [--bot=<name>] [--once](по умолчаниюscope=tenant) -
tenant:telegram:webhook [--tenant=all] [--bot=<name>] [--url=<url>](по умолчаниюscope=tenant) -
tenant:telegram:sync [--tenant=all] [--bot=<name>] [--webhook](по умолчаниюscope=tenant)
У команд provisioning (tenant:db:provision, tenant:provision:*) --tenant обязателен: они перезаписывают
tenant БД (при drop_existing/--drop-existing — удаляют таблицы), поэтому запуск без аргументов не должен
затрагивать всех арендаторов. all передаётся явно.
Отключённые арендаторы (enabled = false) и CLI:
- миграции (
tenant:db:migrate,tenant:db:rollback,tenant:mongo:migrate,tenant:mongo:rollback) и provisioning (tenant:db:provision,tenant:provision:run,tenant:provision:dispatch, задача provisioning вtenant:queue:core:run) работают со всеми арендаторами, включая отключённых: и при явном--tenant=<id>, и при--tenant=all. Арендатора создают отключённым, готовят его БД и только потом включают; схема отключённого арендатора не отстаёт от остальных. Template tenant для provisioning по-прежнему должен быть включён; - остальные команды (очереди, Telegram, Pushr,
tenant:auth:role:*) обслуживают только включённых арендаторов:allпропускает отключённых, явный--tenant=<id>отключённого приводит к ошибкеTenant отключен: <id>; tenant:listбез--allпоказывает только включённых.
tenant:queue:core:run выполняет задачу в контексте арендатора из tenant_id payload. tenant_id должен указывать
ровно на одного включённого арендатора: all и списки через запятую приводят к ошибке задачи.
tenant:db:migrate и tenant:db:rollback для каждого tenant активируют runtime DSN по database_name/data.database.name/data.database.dsn.
В логах команды выводят connection=<alias>, database=<database_name>, чтобы было видно, какая tenant БД обслуживается.
tenant:auth:role:* команды не читают auth-config проекта и не собирают
RoleSynchronizer/UserRoleManager самостоятельно. Приложение должно
зарегистрировать ScopedAuthRoleOperationsResolverInterface, который возвращает
auth-операции для core и tenant runtime:
use PhpSoftBox\MultiTenant\Auth\ScopedAuthRoleOperationsInterface; use PhpSoftBox\MultiTenant\Auth\ScopedAuthRoleOperationsResolverInterface; final class AppScopedAuthRoleOperationsResolver implements ScopedAuthRoleOperationsResolverInterface { public function core(): ScopedAuthRoleOperationsInterface { return $this->buildCoreOperations(); } public function tenant(): ScopedAuthRoleOperationsInterface { return $this->buildTenantOperations(); } }
Для assign/discharge tenant scope требует явный --tenant=<id> или
--tenant=all, чтобы массовое изменение ролей не запускалось случайно.
Конфиг проекта (file provider)
// config/app/tenancy.php return [ 'providers' => [ 'primary' => 'database', // config|database 'enforce_parity' => true, 'database' => [ 'connection' => 'default', ], ], 'tenants' => [ 'tenant-1' => [ 'name' => 'Tenant 1', 'database_connection' => 'tenant', 'database_name' => 'tenant_db_1', 'domains' => [ ['domain' => 'tenant1.chegdesklad.local', 'is_primary' => true], ['domain' => 'www.tenant1.chegdesklad.local'], ], 'data' => [ 'pushr' => [ 'app_id' => 'tenant-1', 'secret' => 'secret-tenant-1', ], 'notifications_config' => [ 'mail' => [ 'smtp' => [ 'from' => 'no-reply@tenant1.chegdesklad.local', ], 'brand' => [ 'name' => 'Tenant 1', 'caption' => 'Tenant Portal', 'logo_url' => 'https://tenant1.chegdesklad.local/assets/logo.svg', 'unsubscribe_url' => 'https://tenant1.chegdesklad.local/profile/subscriptions', 'unsubscribe_label' => 'Управлять подпиской', ], ], 'notifications' => [ 'email' => [ 'view_path' => 'resources/views/tenant', ], ], ], 'telegram_bots' => [ [ 'code' => 'account', 'token' => 'telegram-token-account', 'username' => 'account_bot', 'is_default' => true, 'enabled' => true, ], ], ], 'enabled' => true, ], ], 'provision' => [ 'template_tenant' => 'tenant-template', ], ];
Database provider
DatabaseTenantProvider читает core-таблицы:
tenantsdomains
tenants.user_id (nullable) можно использовать для связи tenant с владельцем из core DB
(FK при необходимости добавляется проектной миграцией).
JSON поля data в ORM Entity маппятся через #[Column(type: 'json')] и DefaultTypeCasterFactory.
В data хранятся tenant-настройки (pushr, fallback-конфиги и т.д.).
Для Telegram поддерживаются оба формата:
telegram_bots/telegram.bots(массив ботов),telegram(single-bot:bot_name/bot_code/bot_token). Tenant-боты применяются только для кодов, которые существуют в coretelegram.bots.
Провайдер использует ORM-entity-классы и поддерживает кастомизацию:
Tenant(по умолчаниюPhpSoftBox\MultiTenant\Entity\Tenant\Tenant)Domain(по умолчаниюPhpSoftBox\MultiTenant\Entity\Tenant\Domain)
Можно передать свои классы (с дополнительными полями/relations), если они реализуют:
TenantEntityInterfaceDomainEntityInterface
Если database_connection не задан, по умолчанию используется alias tenant.
Пример миграции для этих таблиц находится в:
migrations/20260303000100_create_multi_tenant_registry_tables.phpmigrations/20260303000200_create_multi_tenant_telegram_bots_table.php
Проверка совпадения Config и DB
Если нужно одновременно держать file-config и core БД синхронными, используйте ConsistentTenantProvider.
Пример DI:
use PhpSoftBox\Config\Config; use PhpSoftBox\Database\Connection\ConnectionManagerInterface; use PhpSoftBox\MultiTenant\Contracts\TenantProviderInterface; use PhpSoftBox\MultiTenant\Tenant\Provider\ConfigTenantProvider; use PhpSoftBox\MultiTenant\Tenant\Provider\ConsistentTenantProvider; use PhpSoftBox\MultiTenant\Tenant\Provider\DatabaseTenantProvider; use Psr\Container\ContainerInterface; use function DI\factory; return [ TenantProviderInterface::class => factory(static function (ContainerInterface $container): TenantProviderInterface { return new ConsistentTenantProvider( primary: new DatabaseTenantProvider($container->get(ConnectionManagerInterface::class), 'default'), secondary: new ConfigTenantProvider($container->get(Config::class)), enforceParity: true, ); }), ];
Расширяемый Tenant Context (DI-friendly)
Для расширения tenant-настроек без правок ядра:
TenantContextFactoryпринимаетiterable<TenantExtensionLoaderInterface>- каждый loader имеет
key()/priority()/supports(scope)и добавляет extension вTenantContext - данные достаются через
TenantContext::get('key'),TenantContext::get(LoaderClass::class)илиTenantContext::getTyped(SomeConfig::class)
Для runtime-инициализации:
TenantBootstrapPipelineпринимаетiterable<TenantBootstrapperInterface>- bootstrapper имеет
priority()/supports(scope)+bootstrap()/teardown() - teardown выполняется в обратном порядке
- teardown выполняется для всех применённых bootstrapper-ов, даже если teardown одного из них бросил исключение:
состояние арендатора (DSN, namespace кеша, конфиг) не остаётся в долгоживущем процессе. После обхода бросается
первое исключение; если упал и callback
TenantBootstrapPipeline::run(), наружу выходит исключение callback. При ошибкеbootstrap()уже применённые bootstrapper-ы откатываются, наружу выходит исходная ошибка
Четкая граница:
Loaderтолько читает/собирает данные и кладет их вTenantContext(без side-effects).Bootstrapperприменяет/откатывает runtime side-effects на основеTenantContext.
Базовые bootstrappers в компоненте:
DatabaseTenantConnectionBootstrapperTenantOrmEntityManagerBootstrapperTenantNotificationConfigBootstrapperTenantPushrRegistryBootstrapperTenantTelegramRegistryBootstrapperTenantBroadcastChannelBootstrapperTenantCacheNamespaceBootstrapperTenantStorageNamespaceBootstrapper
Базовые loader-ы в компоненте:
TenantNotificationConfigLoaderTenantPushrCredentialsLoaderDatabaseTelegramBotsLoader(core tabletelegram_bots, ORM, configurable entity class)
Реализации switcher-ов в компоненте:
NotificationConfigSwitcher(runtime override выбранных config-ключей, напримерmail.*/notifications.*)PushrConfigSwitcher(runtime overridepushr.app_id/pushr.secretвConfig)TelegramBotRegistrySwitcher(runtime замена bot tokens вTelegramBotRegistry)ChannelRegistryPrefixSwitcher(prefix для broadcaster channel patterns)CacheStoreNamespaceSwitcher(контекстный namespace арендатора черезCache::setContextNamespace(); ключи —<namespace стора>:<namespace арендатора>:<ключ>). Без аргументаstoresизолируется только стор по умолчанию: если приложение обращается к именованным сторам ($cache->store('redis')), перечислите их все вstores, иначе данные в них общие для всех арендаторовStoragePathPrefixSwitcher(runtime path/prefix дляStoragedisks)
TenantDefinition::data уже используется для fallback-переопределений (при включенных соответствующих loader/bootstrapper):
- Pushr:
data.pushr.app_id/data.pushr.secretиdata.pushr_app_id/data.pushr_secret - Notifications:
data.notifications_configилиdata.notifications.config(напримерmail.smtp.from,mail.brand.*,notifications.email.view_path)
Для host-based определения tenant/central domains:
CentralDomainPolicyTenantHostResolver+TenantHostResolution
Определение tenant в HTTP-запросах
TenantResolveMiddleware определяет tenant, записывает его в request-атрибут
_tenant и запускает следующий обработчик внутри TenantRuntimeExecutor с
HTTP scope. После завершения обработчика, включая исключения, runtime выполняет
teardown и восстанавливает предыдущий контекст.
По умолчанию используется HostTenantRequestResolver: host из URI запроса
передаётся в TenantProviderInterface::findByHost(). Существующие вызовы
конструктора middleware продолжают работать. Неизвестный или пустой host
приводит к TenantNotFoundException.
Отключённый арендатор (TenantDefinition::enabled = false) по HTTP не обслуживается: middleware выбрасывает то же
TenantNotFoundException (TenantNotFoundException::forDisabledTenant(), сообщение Tenant is disabled: <id>),
что и для неизвестного арендатора, поэтому приложение отвечает так же (обычно 404) и не раскрывает, что арендатор
существует. Проверка выполняется в middleware после любого resolver (host, заголовок, цепочка, собственный), обработчик
запроса не вызывается, runtime арендатора не активируется. Если приложению нужна отдельная страница «площадка
отключена», её нужно обслуживать вне TenantResolveMiddleware (например, на центральном домене через
TenantHostResolver: TenantHostResolution::shouldBootstrapTenant() для отключённого арендатора возвращает false).
Отдельный TenantHostResolver с
CentralDomainPolicy служит для различения центральных и tenant-доменов;
TenantResolveMiddleware предназначен для маршрутов, где tenant обязателен.
Если API обслуживает несколько tenants на одном домене, можно передавать ID в заголовке. Пример подключения к существующим provider и runtime:
use PhpSoftBox\MultiTenant\Http\Middleware\TenantResolveMiddleware; use PhpSoftBox\MultiTenant\Http\Resolver\HeaderTenantRequestResolver; $middleware = new TenantResolveMiddleware( tenants: $tenantProvider, tenantRuntime: $tenantRuntime, resolver: new HeaderTenantRequestResolver(headerName: 'X-Tenant-Id'), );
Теперь запрос X-Tenant-Id: 42 вызывает findById('42'). Домен может быть общим
для всех tenants и не обязан присутствовать в их настройках. Имя заголовка
настраивается; регистр имени не имеет значения. Значение — строковый ID tenant,
не имя домена, DSN или API-токен. Пробелы по краям ID удаляются.
Для приоритета заголовка с переходом к домену используйте цепочку:
use PhpSoftBox\MultiTenant\Http\Resolver\ChainTenantRequestResolver; use PhpSoftBox\MultiTenant\Http\Resolver\HeaderTenantRequestResolver; use PhpSoftBox\MultiTenant\Http\Resolver\HostTenantRequestResolver; $resolver = new ChainTenantRequestResolver([ new HeaderTenantRequestResolver(), new HostTenantRequestResolver(), ]);
Передайте $resolver в одноимённый аргумент middleware. Порядок элементов задаёт
приоритет: первый найденный tenant завершает поиск. В примере заголовок имеет
приоритет, даже если домен соответствует другому tenant.
- Отсутствующий заголовок позволяет перейти к следующему resolver.
- Переданный пустой заголовок, несколько значений (в том числе через запятую),
неизвестный ID или ID отключённого арендатора вызывают
TenantNotFoundExceptionбез перехода к домену. - В режиме только заголовка его отсутствие также приводит к
TenantNotFoundExceptionв middleware; обработчик не запускается.
Заголовок выбирает tenant, но не предоставляет доступ к нему. Приложение должно проверить право аутентифицированного пользователя или API-токена работать с выбранным tenant до выполнения бизнес-операции. Resolver не заменяет Auth; отключённый tenant отклоняет middleware (см. выше), resolver может вернуть его как найденного.
Собственный способ выбора (например, по атрибуту запроса) реализуется через
TenantRequestResolverInterface::resolve($request, $tenants): ?TenantDefinition.
Middleware передаёт общий provider после reload(), если provider поддерживает
ReloadableTenantProviderInterface. Поэтому resolver не должен хранить свой
provider или повторно обновлять его. null означает, что источник не задан и
цепочка может продолжить поиск; заданный некорректный источник должен приводить
к исключению. HostTenantRequestResolver сохраняет прежнее строгое поведение:
пустой URI host является ошибкой.
Для диагностики middleware сохраняет событие tenant.resolve; вложенное
событие поиска теперь называется tenant.request.resolve вместо
tenant.provider.find_by_host и содержит класс resolver. Если приложение
фильтрует profiler-события по старому имени, фильтр необходимо обновить.
Tenant-aware ORM
Для приложений, где tenant БД активируется в runtime, компонент предоставляет
тонкий tenant-aware слой поверх phpsoftbox/orm:
TenantEntityManagerRegistryInterfaceTenantEntityManagerRegistryTenantEntityManagerInterfaceTenantEntityManager
TenantEntityManagerRegistry оборачивает базовый
PhpSoftBox\Orm\Contracts\EntityAwareEntityManagerRegistryInterface и добавляет
tenant(bool $write = true). Его forEntity() сначала получает имя connection из
ORM metadata, а затем использует тот же кеш, что tenant()/forConnection().
По умолчанию tenant connection называется
tenant, но имя можно переопределить в конструкторе.
use PhpSoftBox\MultiTenant\Orm\TenantEntityManager; use PhpSoftBox\MultiTenant\Orm\TenantEntityManagerInterface; use PhpSoftBox\MultiTenant\Orm\TenantEntityManagerRegistry; use PhpSoftBox\MultiTenant\Orm\TenantEntityManagerRegistryInterface; use PhpSoftBox\MultiTenant\Bootstrap\TenantOrmEntityManagerBootstrapper; use PhpSoftBox\Orm\Contracts\EntityAwareEntityManagerRegistryInterface; use PhpSoftBox\Orm\Contracts\EntityManagerRegistryInterface as BaseEntityManagerRegistryInterface; use Psr\Container\ContainerInterface; use function DI\factory; use function DI\get; return [ TenantEntityManagerRegistryInterface::class => factory( static fn (ContainerInterface $container): TenantEntityManagerRegistryInterface => new TenantEntityManagerRegistry( registry: $container->get(BaseEntityManagerRegistryInterface::class), tenantConnectionName: 'tenant', ), ), EntityAwareEntityManagerRegistryInterface::class => get(TenantEntityManagerRegistryInterface::class), TenantOrmEntityManagerBootstrapper::class => factory( static fn (ContainerInterface $container): TenantOrmEntityManagerBootstrapper => new TenantOrmEntityManagerBootstrapper( $container->get(TenantEntityManagerRegistryInterface::class), ), ), TenantEntityManagerInterface::class => factory( static fn (ContainerInterface $container): TenantEntityManagerInterface => new TenantEntityManager( $container->get(TenantEntityManagerRegistryInterface::class), ), ), ];
TenantEntityManager реализует обычный ORM EntityManagerInterface, но все
операции делегирует в TenantEntityManagerRegistry::tenant(). Поэтому сервисы
приложения могут зависеть от TenantEntityManagerInterface и не передавать имя
tenant connection вручную в каждом repository/query.
Registry кеширует entity-manager по паре read/write + connection. Метод
reset() очищает кеш полностью, а reset('tenant') очищает tenant connection и
runtime-подключения с префиксом tenant.. Это полезно в long-running CLI/worker
процессах после tenant switch/teardown. Перед удалением manager из кеша registry
вызывает UnitOfWorkInterface::clear(): это освобождает identity map, scheduled
operations и ownership сущностей в общем EntityRuntimeRegistry.
Для lifecycle-кода используйте resetTenant(): registry сам применит alias,
переданный ему как tenantConnectionName. Благодаря этому bootstrapper и
приложение не дублируют имя tenant connection.
Если приложение предоставляет собственную реализацию
TenantEntityManagerRegistryInterface, после обновления компонента в ней также
нужно реализовать resetTenant(). Метод должен удалять только tenant manager-ы,
не затрагивая default/core connection.
Время жизни tenant EntityManager
Tenant EntityManager имеет время жизни одного вызова
TenantRuntimeExecutor::run(). TenantOrmEntityManagerBootstrapper сбрасывает
его дважды:
- после активации tenant DSN, перед выполнением callback;
- перед деактивацией DSN, в teardown текущего scope.
Это важно для HTTP-запросов и особенно для долгоживущих CLI, queue, scheduler и WebSocket worker. После простоя соединение предыдущего EntityManager могло быть закрыто сервером БД. Новый runtime scope не переиспользует такой manager и получает новое connection через уже активированный tenant DSN.
Порядок задаётся priority bootstrapper-ов:
bootstrap:
1000 DatabaseTenantConnectionBootstrapper::bootstrap()
950 TenantOrmEntityManagerBootstrapper::bootstrap()
teardown:
950 TenantOrmEntityManagerBootstrapper::teardown()
1000 DatabaseTenantConnectionBootstrapper::teardown()
Pipeline выполняет teardown в обратном порядке, поэтому дополнительная ручная координация не требуется. Для читаемости регистрируйте bootstrappers в таком же порядке:
use PhpSoftBox\MultiTenant\Bootstrap\DatabaseTenantConnectionBootstrapper; use PhpSoftBox\MultiTenant\Bootstrap\TenantBootstrapPipeline; use PhpSoftBox\MultiTenant\Bootstrap\TenantOrmEntityManagerBootstrapper; $pipeline = new TenantBootstrapPipeline([ $container->get(DatabaseTenantConnectionBootstrapper::class), $container->get(TenantOrmEntityManagerBootstrapper::class), // Остальные tenant bootstrappers. ]);
Bootstrapper поддерживает все TenantRuntimeScope: HTTP, CLI, Queue, Scheduler и
Websocket. Во вложенном runtime tenant A → tenant B → tenant A manager tenant A
сбрасывается перед входом в B. После teardown B connection manager восстанавливает
DSN tenant A, а следующее обращение создаёт для A новый EntityManager.
Долгоживущие сервисы должны зависеть от TenantEntityManagerInterface: это
ленивый proxy, который получает актуальный manager при каждой операции. Нельзя
сохранять конкретный tenant EntityManagerInterface или загруженные им entity и
передавать их в другой runtime scope.
Сброс не затрагивает core/default EntityManager и не повторяет SQL-запросы.
Исключения создания connection или выполнения callback не скрываются
TenantRuntimeExecutor; неперехваченное прикладным кодом исключение выходит в
queue worker и обрабатывается его штатной retry/failure policy.
runtimeRegistry() делегируется базовому ORM registry и остаётся одним и тем же
объектом при смене tenant. Его можно безопасно передать в Resource
OrmRelationStateProvider: relation-state будет найден по экземпляру entity, а не
по текущему tenant или однажды выбранному UnitOfWork.
Долгоживущие процессы и ServicesResetter
Состояние арендатора (контекст, DSN, namespace кеша и storage, переопределения конфига) применяется и откатывается
TenantRuntimeExecutor::run(), поэтому отдельный сброс ему не нужен. Сбрасывать между задачами/запросами нужно
tenant EntityManager — identity map и несохранённые изменения. Компонент не зависит от phpsoftbox/container;
подключите методы в карту хуков ServicesResetter:
new ServicesResetter($container, [ // ... TenantEntityManagerRegistryInterface::class => 'reset', ]);
reset() очищает UnitOfWork всех закешированных manager-ов и забывает их; следующее обращение создаёт новый manager
для активного DSN, поэтому хук безопасен и внутри tenant:queue:tenant:run, где сброс воркера выполняется внутри
tenant runtime. Не добавляйте в хуки TenantContextStoreInterface::clear() и переключатели: в режиме
tenant:queue:tenant:run это сбросило бы активного арендатора посреди обработки очереди.
Router binding должен указывать на tenant-aware registry, иначе route entity будет загружена отдельным manager базового registry:
EntityAwareEntityManagerRegistryInterface::class => get(TenantEntityManagerRegistryInterface::class),
Tenant changelog
Для tenant-aware аудита ORM изменений доступны:
TenantMongoEntityChangeLoggerTenantEntityChangeContextResolverEntityChangeInitiatorResolverInterface
TenantMongoEntityChangeLogger пишет changelog в MongoDB текущего tenant. Он
ожидает provider с методом collection(string): object, а collection object
должен иметь метод insertOne(array).
use PhpSoftBox\MultiTenant\Orm\ChangeLog\TenantMongoEntityChangeLogger; $logger = new TenantMongoEntityChangeLogger( tenantMongo: $tenantMongo, logger: $psrLogger, collection: 'entity_changelog', technicalFields: ['created_datetime', 'updated_datetime', 'deleted_datetime'], );
Update-записи, где изменились только технические поля из technicalFields,
пропускаются. Ошибки записи в MongoDB не прерывают основной ORM flow: logger
пишет warning в PSR logger, если он передан.
Подключение к ORM выполняется через стандартный changelog handler сущности:
use PhpSoftBox\MultiTenant\Orm\ChangeLog\TenantMongoEntityChangeLogger; use PhpSoftBox\Orm\Attribute\Changelog; #[Changelog(logHandler: TenantMongoEntityChangeLogger::class)] final class Product { }
TenantEntityChangeContextResolver добавляет в changelog context:
initiatorIdиз подключенногоEntityChangeInitiatorResolverInterface;initiatorType = user, если initiator найден, иначеsystem;- metadata
tenant_idизTenantContextResolver; - metadata
request_methodиrequest_path, если передан request.
use PhpSoftBox\MultiTenant\Orm\ChangeLog\Initiator\AuthManagerEntityChangeInitiatorResolver; use PhpSoftBox\MultiTenant\Orm\ChangeLog\Initiator\ChainEntityChangeInitiatorResolver; use PhpSoftBox\MultiTenant\Orm\ChangeLog\Initiator\RequestAttributeEntityChangeInitiatorResolver; use PhpSoftBox\MultiTenant\Orm\ChangeLog\TenantEntityChangeContextResolver; $initiatorResolver = new ChainEntityChangeInitiatorResolver([ new RequestAttributeEntityChangeInitiatorResolver($request), new AuthManagerEntityChangeInitiatorResolver( request: $request, auth: $auth, guard: 'tenant', ), ]); $contextResolver = new TenantEntityChangeContextResolver( tenantResolver: $tenantContextResolver, initiatorResolver: $initiatorResolver, request: $request, );
AuthManagerEntityChangeInitiatorResolver читает пользователя из одного guard.
Если нужен fallback между guards, соберите его явно через
ChainEntityChangeInitiatorResolver.
По умолчанию RequestAttributeEntityChangeInitiatorResolver читает scalar-id из
request attribute user_id, а пользователя из request attribute user.
Если приложение использует другие request attributes, укажите их явно:
$initiatorResolver = new RequestAttributeEntityChangeInitiatorResolver( request: $request, userIdAttribute: 'auth_user_id', userAttribute: '_authUser', );
В CLI/worker можно передать initiator явно:
use PhpSoftBox\MultiTenant\Orm\ChangeLog\Initiator\StaticEntityChangeInitiatorResolver; use PhpSoftBox\MultiTenant\Orm\ChangeLog\TenantEntityChangeContextResolver; $contextResolver = new TenantEntityChangeContextResolver( tenantResolver: $tenantContextResolver, initiatorResolver: new StaticEntityChangeInitiatorResolver($job->initiatorUserId), );
Если initiator не передан, resolver вернет system-context и, если tenant context
активен, добавит только tenant_id.
Profiler
Компонент поддерживает phpsoftbox/profiler через:
MultiTenantProfilerCollectorMultiTenantProfilerExtension
В trace появляется section multi_tenant с событиями tenant lifecycle:
tenant.resolvetenant.provider.reloadtenant.provider.find_by_hosttenant.runtimetenant.bootstraptenant.bootstrap.teardowntenant.bootstrap.rollbacktenant.connection.activatetenant.connection.createtenant.connection.reusetenant.connection.deactivate
Runtime tenant-подключения, которые создает TenantAwareConnectionManager, должны получать
тот же ProfilerInterface и DatabaseProfilerCollector, что и основная DatabaseFactory.
Тогда SQL из tenant БД попадает в общий database section, а не теряется как отдельный runtime connection.
В событиях не сохраняется DSN. Разрешены только безопасные теги: tenant_id, tenant_name,
host, scope, connection, connection_alias, bootstrapper.
События записываются только при включённом профайлере и активной трассе: вне трассы collector никто
не прочитает и не очистит. Список событий ограничен 5 000 записями (new MultiTenantProfilerCollector(maxItems: ...)),
после предела растёт только счётчик, а в summary появляются truncated и dropped. Подробнее — в README
phpsoftbox/profiler, раздел «Память в долгих процессах».
Пример DI:
use PhpSoftBox\MultiTenant\Bootstrap\TenantBootstrapPipeline; use PhpSoftBox\MultiTenant\Context\TenantContextFactory; use Psr\Container\ContainerInterface; use function DI\factory; return [ TenantContextFactory::class => factory(static function (ContainerInterface $container): TenantContextFactory { return new TenantContextFactory([ $container->get(App\Tenancy\Loader\PushrTenantLoader::class), $container->get(App\Tenancy\Loader\TelegramTenantLoader::class), ]); }), TenantBootstrapPipeline::class => factory(static function (ContainerInterface $container): TenantBootstrapPipeline { return new TenantBootstrapPipeline([ $container->get(App\Tenancy\Bootstrap\DatabaseConnectionBootstrapper::class), $container->get(App\Tenancy\Bootstrap\TelegramRegistryBootstrapper::class), ]); }), ];
Важно про создание tenant БД
Для provisioning нового tenant рекомендуется шаблонный подход:
- создать tenant БД как копию schema существующего template tenant без бизнес-данных
- оставить данные только в таблице
migrations - дальше поддерживать схему через
tenant:db:migrate
Команда tenant:db:provision поддерживает этот workflow:
- создаёт target БД (если не существует)
- копирует структуру таблиц из template tenant
- копирует данные только из таблицы миграций (
migrationsили--migrations-table) - при непустой target БД требует явный
--drop-existing
Команды после provisioning (CliCommandListProvisionStep)
tenant:provision:* после клонирования БД выполняет команды из tenancy.provision.commands (или extra.commands
payload). Каждая команда запускается отдельным процессом tenancy.provision.command_runner.binary (по умолчанию
php psb) без shell:
- шаблон команды разбивается на аргументы до подстановки: пробелы разделяют аргументы,
"..."и'...'группируют (кавычки в аргумент не попадают),\экранирует символ; операторы shell (;,|,$(),>) не интерпретируются; - placeholders (
{tenant_id},{tenant_connection},{tenant_database},{template_tenant_id},{template_connection},{owner_phone},{owner_name},{owner_email},{confirm_owner_phone}) заменяются внутри аргумента; значение из payload всегда остаётся частью одного аргумента, поэтому имя владельца с кавычками или$(...)не может внедрить команду; - неизвестный placeholder — ошибка до запуска команды.
'commands' => [ 'tenant:auth:role:sync --scope=tenant --tenant={tenant_id}', 'tenant:user:create {owner_phone} --tenant={tenant_id} --name="{owner_name}" --email={owner_email}', ],
Собственный запускатель реализует TenantProvisionCommandRunnerInterface::run(list<string> $arguments) — получает
аргументы команды без бинарника.