PHPackages                             phpsoftbox/multi-tenant - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. phpsoftbox/multi-tenant

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

phpsoftbox/multi-tenant
=======================

Multi-tenant extension for PhpSoftBox

036PHP

Since Aug 3Pushed 2w agoCompare

[ Source](https://github.com/phpsoftbox/multi-tenant)[ Packagist](https://packagist.org/packages/phpsoftbox/multi-tenant)[ RSS](/packages/phpsoftbox-multi-tenant/feed)WikiDiscussions master Synced 1w ago

READMEChangelogDependenciesVersions (1)Used By (0)

PhpSoftBox MultiTenant
======================

[](#phpsoftbox-multitenant)

`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-реестра:
    - `ConfigTenantProvider`
    - `DatabaseTenantProvider` (core БД, JSON payload `data` через 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=all] [--template=] [--migrations-table=migrations] [--drop-existing] [--fail-fast]`
- `tenant:pushr:serve [--tenant=all] [--host=0.0.0.0] [--port=8080] [--max-skew=300]`
- `tenant:pushr:serve:registry [--tenant=all] [--host=0.0.0.0] [--port=8080] [--max-skew=300] [--without-default-app]`
- `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   [--scope=tenant] [--tenant=|all]`
- `tenant:auth:role:discharge   [--scope=tenant] [--tenant=|all]`
- `tenant:telegram:poll [--tenant=all] [--bot=] [--once]` (по умолчанию `scope=tenant`)
- `tenant:telegram:webhook [--tenant=all] [--bot=] [--url=]` (по умолчанию `scope=tenant`)
- `tenant:telegram:sync [--tenant=all] [--bot=] [--webhook]` (по умолчанию `scope=tenant`)

`tenant:db:migrate` и `tenant:db:rollback` для каждого tenant активируют runtime DSN по `database_name`/`data.database.name`/`data.database.dsn`. В логах команды выводят `connection=, database=`, чтобы было видно, какая 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=` или `--tenant=all`, чтобы массовое изменение ролей не запускалось случайно.

Конфиг проекта (file provider)
------------------------------

[](#конфиг-проекта-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
-----------------

[](#database-provider)

`DatabaseTenantProvider` читает core-таблицы:

- `tenants`
- `domains`

`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-боты применяются только для кодов, которые существуют в core `telegram.bots`.

Провайдер использует ORM-entity-классы и поддерживает кастомизацию:

- `Tenant` (по умолчанию `PhpSoftBox\MultiTenant\Entity\Tenant\Tenant`)
- `Domain` (по умолчанию `PhpSoftBox\MultiTenant\Entity\Tenant\Domain`)

Можно передать свои классы (с дополнительными полями/relations), если они реализуют:

- `TenantEntityInterface`
- `DomainEntityInterface`

Если `database_connection` не задан, по умолчанию используется alias `tenant`.

Пример миграции для этих таблиц находится в:

- `migrations/20260303000100_create_multi_tenant_registry_tables.php`
- `migrations/20260303000200_create_multi_tenant_telegram_bots_table.php`

Проверка совпадения Config и DB
-------------------------------

[](#проверка-совпадения-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-context-di-friendly)

Для расширения tenant-настроек без правок ядра:

- `TenantContextFactory` принимает `iterable`
- каждый loader имеет `key()/priority()/supports(scope)` и добавляет extension в `TenantContext`
- данные достаются через `TenantContext::get('key')`, `TenantContext::get(LoaderClass::class)` или `TenantContext::getTyped(SomeConfig::class)`

Для runtime-инициализации:

- `TenantBootstrapPipeline` принимает `iterable`
- bootstrapper имеет `priority()/supports(scope)` + `bootstrap()/teardown()`
- teardown выполняется в обратном порядке

Четкая граница:

- `Loader` только читает/собирает данные и кладет их в `TenantContext` (без side-effects).
- `Bootstrapper` применяет/откатывает runtime side-effects на основе `TenantContext`.

Базовые bootstrappers в компоненте:

- `DatabaseTenantConnectionBootstrapper`
- `TenantNotificationConfigBootstrapper`
- `TenantPushrRegistryBootstrapper`
- `TenantTelegramRegistryBootstrapper`
- `TenantBroadcastChannelBootstrapper`
- `TenantCacheNamespaceBootstrapper`
- `TenantStorageNamespaceBootstrapper`

Базовые loader-ы в компоненте:

- `TenantNotificationConfigLoader`
- `TenantPushrCredentialsLoader`
- `DatabaseTelegramBotsLoader` (core table `telegram_bots`, ORM, configurable entity class)

Реализации switcher-ов в компоненте:

- `NotificationConfigSwitcher` (runtime override выбранных config-ключей, например `mail.*`/`notifications.*`)
- `PushrConfigSwitcher` (runtime override `pushr.app_id/pushr.secret` в `Config`)
- `TelegramBotRegistrySwitcher` (runtime замена bot tokens в `TelegramBotRegistry`)
- `ChannelRegistryPrefixSwitcher` (prefix для broadcaster channel patterns)
- `CacheStoreNamespaceSwitcher` (runtime namespace для `CacheStore`)
- `StoragePathPrefixSwitcher` (runtime path/prefix для `Storage` disks)

`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:

- `CentralDomainPolicy`
- `TenantHostResolver` + `TenantHostResolution`

Tenant-aware ORM
----------------

[](#tenant-aware-orm)

Для приложений, где tenant БД активируется в runtime, компонент предоставляет тонкий tenant-aware слой поверх `phpsoftbox/orm`:

- `TenantEntityManagerRegistryInterface`
- `TenantEntityManagerRegistry`
- `TenantEntityManagerInterface`
- `TenantEntityManager`

`TenantEntityManagerRegistry` оборачивает базовый `PhpSoftBox\Orm\Contracts\EntityManagerRegistryInterface` и добавляет `tenant(bool $write = true)`. По умолчанию 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\Orm\Contracts\EntityManagerRegistryInterface as BaseEntityManagerRegistryInterface;
use Psr\Container\ContainerInterface;

use function DI\factory;

return [
    TenantEntityManagerRegistryInterface::class => factory(
        static fn (ContainerInterface $container): TenantEntityManagerRegistryInterface => new TenantEntityManagerRegistry(
            registry: $container->get(BaseEntityManagerRegistryInterface::class),
            tenantConnectionName: 'tenant',
        ),
    ),

    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.

Tenant changelog
----------------

[](#tenant-changelog)

Для tenant-aware аудита ORM изменений доступны:

- `TenantMongoEntityChangeLogger`
- `TenantEntityChangeContextResolver`
- `EntityChangeInitiatorResolverInterface`

`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
--------

[](#profiler)

Компонент поддерживает `phpsoftbox/profiler` через:

- `MultiTenantProfilerCollector`
- `MultiTenantProfilerExtension`

В trace появляется section `multi_tenant` с событиями tenant lifecycle:

- `tenant.resolve`
- `tenant.provider.reload`
- `tenant.provider.find_by_host`
- `tenant.runtime`
- `tenant.bootstrap`
- `tenant.bootstrap.teardown`
- `tenant.bootstrap.rollback`
- `tenant.connection.activate`
- `tenant.connection.create`
- `tenant.connection.reuse`
- `tenant.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`.

Пример 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 БД
----------------------------

[](#важно-про-создание-tenant-бд)

Для provisioning нового tenant рекомендуется шаблонный подход:

1. создать tenant БД как копию schema существующего template tenant без бизнес-данных
2. оставить данные только в таблице `migrations`
3. дальше поддерживать схему через `tenant:db:migrate`

Команда `tenant:db:provision` поддерживает этот workflow:

- создаёт target БД (если не существует)
- копирует структуру таблиц из template tenant
- копирует данные только из таблицы миграций (`migrations` или `--migrations-table`)
- при непустой target БД требует явный `--drop-existing`

###  Health Score

23

—

LowBetter than 25% of packages

Maintenance63

Regular maintenance activity

Popularity9

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/0279d150240c97d210034878b0467462246dc14d29b5618157ff6a8be49a50e3?d=identicon)[inspector-who](/maintainers/inspector-who)

---

Top Contributors

[![inspector-who](https://avatars.githubusercontent.com/u/6973963?v=4)](https://github.com/inspector-who "inspector-who (2 commits)")

### Embed Badge

![Health badge](/badges/phpsoftbox-multi-tenant/health.svg)

```
[![Health](https://phpackages.com/badges/phpsoftbox-multi-tenant/health.svg)](https://phpackages.com/packages/phpsoftbox-multi-tenant)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
