PHPackages                             saifulferoz/multi-tenancy-bundle - 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. [Database &amp; ORM](/categories/database)
4. /
5. saifulferoz/multi-tenancy-bundle

ActiveSymfony-bundle[Database &amp; ORM](/categories/database)

saifulferoz/multi-tenancy-bundle
================================

Multi-tenancy for Symfony: pluggable isolation strategies with tenant-safe defaults.

v1.1.0(1w ago)00MITPHPPHP &gt;=8.3CI failing

Since Aug 5Pushed 1w agoCompare

[ Source](https://github.com/saifulferoz/multi-tenancy-bundle)[ Packagist](https://packagist.org/packages/saifulferoz/multi-tenancy-bundle)[ RSS](/packages/saifulferoz-multi-tenancy-bundle/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (16)Versions (3)Used By (0)

Multi-Tenancy Bundle
====================

[](#multi-tenancy-bundle)

Multi-tenancy for Symfony with tenant-safe defaults.

Tested on PHP 8.3–8.5 × Symfony 7.4 and 8.x, including a lowest-dependency lane.

Every isolation decision in this bundle was verified against real Doctrine and Symfony behaviour rather than assumed. The reasoning — including the places the original design turned out to be wrong — is recorded in:

- [PLAN.md](PLAN.md) — architecture, phasing, **§8.1 outstanding work by risk**, and **§8.2 where the design was wrong**
- [CHANGELOG.md](CHANGELOG.md) — what shipped in each release

The sharp edges that drove each decision are documented inline, next to the code that handles them, and pinned down by `tests/Security/`.

Status
------

[](#status)

PhaseStateTenant context, resolvers, registrydoneDiscriminator isolation + write protectiondoneDI wiringdoneMulti-database isolationdonePer-tenant auth &amp; RBACdoneAPI (stateless) authdoneMessenger tenant propagationdoneCache key prefixingdoneSchema audit commanddoneHeterogeneous platform detectiondonePer-tenant issuer validation (Keycloak realms)doneEverything above ships in 1.1.0.

**Not yet built** — see [PLAN.md §8.1](PLAN.md): per-tenant SSO (OIDC/SAML), schema-per-tenant isolation, a serialization leak guard, and an API response envelope. These are additive features rather than gaps: every known silent-failure mode is closed and covered by tests.

Installation
------------

[](#installation)

```
composer require saifulferoz/multi-tenancy-bundle
```

```
// config/bundles.php
return [
    Feroz\MultiTenancyBundle\FerozMultiTenancyBundle::class => ['all' => true],
];
```

```
# config/packages/multi_tenancy.yaml — subdomain-per-tenant web app
multi_tenancy:
    preset: web
    registry:
        entity: App\Entity\Tenant
    resolvers:
        subdomain:
            base_domain: app.example.com
```

```
# config/packages/multi_tenancy.yaml — token-authenticated API
multi_tenancy:
    preset: api
    registry:
        entity: App\Entity\Tenant
    security:
        membership_provider: App\Security\MembershipProvider
```

A preset is a starting point, not a lock: **every key it sets can be overridden by stating it explicitly.** `preset: api` turns off subdomain resolution and turns on the API tenant assertion, so an API-first application never configures a base domain for subdomains it does not have.

For the web preset, `base_domain` is required. The tenant label is found by stripping this suffix; guessing it by counting dots breaks on multi-label suffixes such as `co.uk` and on staging hosts like `acme.staging.app.example.com`.

The tenant entity
-----------------

[](#the-tenant-entity)

```
use Feroz\MultiTenancyBundle\Doctrine\Attribute\TenantShared;
use Feroz\MultiTenancyBundle\Tenant\TenantInterface;

#[ORM\Entity]
#[TenantShared]                       // required, see below
class Tenant implements TenantInterface
{
    #[ORM\Id, ORM\Column]
    public int $id;

    #[ORM\Column(unique: true)]
    public string $identifier;        // the subdomain label

    #[ORM\Column]
    public bool $active = true;

    public function getId(): string|int { return $this->id; }
    public function getIdentifier(): string { return $this->identifier; }
    public function isActive(): bool { return $this->active; }
}
```

The tenant entity **must** be `#[TenantShared]`. It is looked up before a tenant is known, so filtering it by tenant would be circular and would resolve nothing.

Lookups are memoised per request, since resolution runs on every request. `EntityManager::clear()` during a tenant switch detaches those instances — they stay readable, which is all resolution needs, but **treat them as read-only**: mutating a detached entity and flushing silently discards the change.

Leave `registry.entity` unset to get an empty in-memory registry (nothing resolves), or override the `TenantRegistryInterface` alias with your own.

Marking entities
----------------

[](#marking-entities)

Every entity must declare its relationship to tenancy. This is enforced at runtime because silently leaving an entity unfiltered is how tenant leaks ship.

```
use Feroz\MultiTenancyBundle\Doctrine\Attribute\TenantAware;
use Feroz\MultiTenancyBundle\Doctrine\Attribute\TenantShared;
use Feroz\MultiTenancyBundle\Doctrine\TenantOwnedTrait;
use Feroz\MultiTenancyBundle\Security\TenantOwnedInterface;

#[ORM\Entity]
#[TenantAware]                                  // scoped to one tenant
class Invoice implements TenantOwnedInterface
{
    use TenantOwnedTrait;                       // column + accessor
}

#[ORM\Entity]
#[TenantShared]                                 // global reference data
class Currency {}
```

`TenantOwnedTrait` supplies the mapped `tenant_id` column and the accessor the permission voter needs, so tenancy costs one attribute, one interface and one `use` per entity rather than the same four things written out each time.

**Implement `TenantOwnedInterface` on anything you pass to `isGranted()`.**Without it the voter cannot tell a tenant-owned subject from a value object, so its ownership check silently degrades to "permission alone". The trait exists largely to make that hard to forget.

Bring your own column instead with `#[TenantAware(field: 'ownerId')]` if you need a different name or a non-integer tenant key.

Marking is inherited, so a subclass cannot escape it. For incremental adoption on an existing codebase:

```
multi_tenancy:
    unmarked_entities: allow   # treat unmarked entities as shared
```

Usage
-----

[](#usage)

Within a request the tenant is resolved automatically:

```
public function __construct(private TenantContextInterface $tenants) {}

public function index(): Response
{
    $tenant = $this->tenants->getTenant();   // throws if none resolved
}
```

Outside a request — console commands, message handlers, cross-tenant jobs:

```
$this->tenants->runFor($tenant, function () {
    // reads and writes are scoped to $tenant here
});
```

Always prefer `runFor()`. It clears the EntityManager on entry and exit and restores the previous tenant even when the callback throws.

Why `runFor()` clears the EntityManager
---------------------------------------

[](#why-runfor-clears-the-entitymanager)

Doctrine's SQL filter applies when SQL is generated. A read answered from the UnitOfWork identity map issues no SQL, so no filter runs — and the identity map is keyed by class + id with no notion of a tenant, while row ids collide across tenants by construction.

Without clearing, this returns another tenant's row:

```
$em->getFilters()->enable('tenant')->setParameter('tenant_id', 1);
$em->find(Doc::class, 1);                     // tenant 1's row, cached

$em->getFilters()->enable('tenant')->setParameter('tenant_id', 2);
$em->find(Doc::class, 1);                     // still tenant 1's row
```

`clear()` is therefore a security control, not an optimisation, and `TenantContext` owns it rather than trusting callers. `tests/Security/` pins this down; the tests were verified by mutation — disabling the clear fails them.

This applies to `strategy: database` too, and the consequence there is worse. With the connection correctly pointed at another tenant's database, an entity loaded before the switch is still served from the identity map, and flushing it writes into the **wrong tenant's database** while leaving the original untouched — no error, nothing logged. Ids collide across tenant databases by construction, so this is the normal case rather than an edge case.

Isolation strategies
--------------------

[](#isolation-strategies)

### `discriminator` (default)

[](#discriminator-default)

One database, a `tenant_id` column on every `#[TenantAware]` entity, enforced by a Doctrine SQL filter on read and by a flush listener on write.

### `database`

[](#database)

One database per tenant.

```
multi_tenancy:
    strategy: database
    database:
        dsn_template: 'mysql://user:pass@host/tenant_%identifier%'
        max_connections: 5
```

The `%identifier%` placeholder is required — without it every tenant would resolve to the same database, which is silent cross-tenant access rather than an obvious failure. Identifiers are re-validated before substitution, because they reach a DSN and, during provisioning, `CREATE DATABASE`.

Connections are pooled per tenant and bounded by `max_connections`, so a worker iterating hundreds of tenants cannot exhaust the server's connection limit. Pooling lives inside a DBAL driver wrapper: DBAL calls `connect()` again after every `close()`, so a pool wrapped around the `Connection` would hold nothing.

**Switching is refused while a transaction is open.** DBAL's `close()` discards an in-flight transaction and resets the nesting level without raising anything, so the writes would vanish silently.

**All tenants must be on one database platform.** DBAL caches the platform on first use and never re-derives it, so a tenant on a different engine would get SQL built for the first one's platform — silently, since SQLite accepts MySQL's backtick quoting. A mismatched tenant is rejected rather than served.

Note that swapping the database does **not** isolate on its own — see below.

Per-tenant authentication and authorization
-------------------------------------------

[](#per-tenant-authentication-and-authorization)

Optional, and off by default. Requires `symfony/security-bundle`.

```
multi_tenancy:
    security:
        enabled: true
        membership_provider: App\Security\MembershipProvider
        api:
            enabled: true          # for token-authenticated requests
```

### Why a Symfony session alone is not enough

[](#why-a-symfony-session-alone-is-not-enough)

A Symfony token stores the user identifier, roles and firewall name — and **nothing identifying a tenant**. With a session cookie shared across subdomains, a session established on `acme.app.example.com` deserializes intact on `globex.app.example.com`. Pair that with a user provider that reloads by identifier alone, which is the obvious implementation, and a user authenticated in *any* tenant is authenticated in *every* tenant.

Two independent defences close this, and each is tested with the other disabled:

1. **Token binding** — `TenantTokenAssertionListener` rejects a token whose tenant differs from the active one, and invalidates the session.
2. **Membership re-verification** — `TenantUserProvider` reloads a user only if they hold an active membership in the active tenant.

Defence 2 signals rejection with `UserNotFoundException` because that is Symfony's own deauthentication path; any other exception escapes as a 500.

**Defence 2 does not exist on stateless firewalls.** Symfony only registers `ContextListener` — and therefore `refreshUser()` — when `stateless: false`, so API requests rely on defence 1 and the voter instead.

### What you have to implement

[](#what-you-have-to-implement)

Authorization derives entirely from the membership, never from the user row: a role granted on the user would apply in every tenant at once. So there is no default — you supply the lookup.

```
use Feroz\MultiTenancyBundle\Security\TenantMembershipInterface;
use Feroz\MultiTenancyBundle\Security\TenantMembershipProviderInterface;

final class MembershipProvider implements TenantMembershipProviderInterface
{
    public function findMembership(
        string $userIdentifier,
        TenantInterface $tenant,
    ): ?TenantMembershipInterface {
        // null means "not a member here", which reads as "not authenticated
        // here" — that is the intended meaning, not an error.
        return $this->repository->findOneBy([
            'user' => $userIdentifier,
            'tenant' => $tenant->getId(),
        ]);
    }
}
```

A membership answers five questions:

```
interface TenantMembershipInterface
{
    public function getUserIdentifier(): string;
    public function getTenantId(): string|int;

    /** @return list tenant-local role names */
    public function getRoles(): array;

    /** @return list effective permissions, already flattened */
    public function getPermissions(): array;

    public function isActive(): bool;
}
```

`getPermissions()` returns the flattened set, not roles to expand. The bundle does not know how a tenant maps roles to permissions — that mapping is yours, and keeping it out of the interface is what lets "Editor" mean different things in different tenants.

There is no permission cache: the provider is consulted on each `isGranted()`, so revoking a membership takes effect on the next request rather than when a token expires. If that lookup is hot, cache it in your provider — and key the cache by tenant.

### Authorization

[](#authorization)

Application code checks **permissions**, never role names:

```
$this->denyAccessUnlessGranted('post.publish', $post);   // yes
$this->denyAccessUnlessGranted('ROLE_EDITOR');           // no
```

A tenant defines what "Editor" means, so checking `ROLE_EDITOR` hard-codes one tenant's vocabulary. Symfony's `role_hierarchy` cannot express per-tenant roles either — it compiles into the container, one hierarchy per application.

Passing the subject matters. `TenantPermissionVoter` denies a permission that is genuinely held when the subject belongs to another tenant, which is the IDOR the Doctrine filter does not always catch.

That check only runs on subjects that can answer which tenant owns them:

```
use Feroz\MultiTenancyBundle\Security\TenantOwnedInterface;

#[ORM\Entity]
#[TenantAware]
class Post implements TenantOwnedInterface
{
    #[ORM\Column(name: 'tenant_id')]
    public ?int $tenantId = null;

    public function getTenantId(): string|int|null
    {
        return $this->tenantId;
    }
}
```

**Without the interface the ownership check silently does nothing** — the voter falls back to "permission alone", because it cannot tell a tenant-owned subject from a value object or a plain string. Implement it on anything you pass as a subject. A `null` owner is treated as *not* belonging to the active tenant rather than belonging to everyone.

Permission strings are opaque to the bundle, so a typo denies access rather than erroring. That direction is the safe one, but nothing surfaces the mistake — there is no permission catalogue or lint command.

### API requests

[](#api-requests)

The tenant comes from the **verified token claim**, never a client header.

`AccessTokenHandlerInterface::getUserBadgeFrom()` receives only the raw token string — no `Request`, no host — so it cannot compare the token's tenant against the URL's, and Symfony compares nothing either. Without `ApiTenantAssertionListener`, a token issued for one tenant and presented at another tenant's host is served as that other tenant.

A tenant header is advisory. Under the default `require_match` policy, a header disagreeing with the claim is a **403** rather than being silently resolved in favour of either side. Rejections name no tenant, so the endpoint cannot be used to enumerate tenants.

### One identity-provider realm per tenant

[](#one-identity-provider-realm-per-tenant)

If each tenant has its own Keycloak realm (or equivalent), **the tenant claim inside a token is not sufficient on its own.** Any realm your application trusts can mint a token carrying any claim value, so a token signed by tenant B's realm and claiming `tenant: acme` is correctly signed and passes the claim check. Only the issuer distinguishes them.

```
multi_tenancy:
    security:
        api:
            issuer:
                enabled: true
```

Then let the tenant entity carry its realm:

```
use Feroz\MultiTenancyBundle\Security\Api\TenantIssuerAware;

class Tenant implements TenantInterface, TenantIssuerAware
{
    #[ORM\Column(nullable: true)]
    public ?string $issuer = null;   // https://kc.example.com/realms/acme

    public function getIssuer(): ?string { return $this->issuer; }
}
```

`TenantIssuerAssertionListener` runs at priority 5 — after the firewall, and *before* the claim assertion at 4 — so a token from the wrong provider is rejected before its claims are trusted for anything.

Three behaviours worth knowing:

- **A tenant expecting an issuer rejects a token that has none.** Accepting it would make the check skippable by omitting the claim, which is exactly the token an attacker would craft.
- **The comparison is exact**, not a prefix — `…/realms/acme-evil` starts with `…/realms/acme`.
- **A tenant with no configured issuer is not enforced**, so tenants on a shared realm still work; the claim check continues to apply to them.

The issuer is read from the verified claims your authenticator left on the token — `claims`, `payload`, `jwt_payload` or `token_payload`, or the `iss`attribute directly. The bundle never parses the raw JWT itself: an unverified payload is attacker-controlled, and checking it would look like a control while being none. Point `issuer.provider` at your own service if the tenant-to-issuer mapping lives outside the entity.

Cache
-----

[](#cache)

Optional, off by default. Requires `symfony/cache`.

```
multi_tenancy:
    cache:
        enabled: true
        pools:
            - app.cache.stats
            - app.cache.reports
```

Named pools are decorated so every key is namespaced by the active tenant. A shared backend with unprefixed keys is a leak no Doctrine filter catches — the ORM never sees the read, so two tenants caching `dashboard.stats` get each other's numbers.

**Pools are named explicitly rather than decorated wholesale.** Symfony's system caches (router, validator, container metadata) are written before any tenant is resolved and are global by design; namespacing them per tenant would break boot. Entries cached with no tenant active go under a separate `shared` namespace.

Two things worth knowing:

- **The separator is `.`, not `:`.** PSR-6 reserves `{}()/\@:` in cache keys, so the conventional `tenant:acme:key` form throws on every call.
- **`clear()` is scoped to the active tenant**, and is *refused* on a plain PSR-6 pool that cannot clear by prefix — silently wiping every tenant's cache during a routine `cache:clear` is not a good default.

Messenger
---------

[](#messenger)

Optional, off by default. Requires `symfony/messenger`.

```
multi_tenancy:
    messenger:
        enabled: true
```

The middleware stamps the active tenant on dispatch and re-establishes it for the duration of the handler on consumption.

**Without it, a handler runs under whatever tenant the worker last touched.**That is worse than "no tenant": it is a plausible-looking *wrong* tenant, so the handler reads and writes real data belonging to another customer, silently.

Two behaviours worth knowing:

- **A message arriving with no tenant stamp is refused.** Handling it in an arbitrary scope is the failure this exists to prevent. Set `require_stamp: false` if you have handlers that genuinely need no tenant — they then run with none, and fail closed at the first tenant-scoped query.
- **The stamp holds an identifier, not the tenant object**, and is re-resolved through the registry on consumption. Queues can be hours deep, so a tenant deactivated or removed after dispatch is rejected rather than resurrected from a stale snapshot.

To dispatch deliberately for another tenant — an admin action, say — add the stamp yourself; an explicit stamp is never overwritten:

```
$bus->dispatch(new Envelope($message, [new TenantStamp('acme')]));
```

Console
-------

[](#console)

```
bin/console tenant:list
```

`tenant:schema:audit` reports schema-level tenancy problems the runtime filter cannot catch:

```
bin/console tenant:schema:audit
```

The filter constrains *reads*; it says nothing about whether the schema lets two tenants collide. The classic case is `UNIQUE(slug)` on a tenant-aware entity — correct for every query, and fine right up until a second tenant picks a slug the first already used, at which point that tenant simply cannot save.

It reports as errors:

- a globally unique field or unique constraint on a `#[TenantAware]` entity that omits the tenant column
- a `#[TenantShared]` entity holding an association to tenant-owned data, which means the "global" row belongs to one tenant and is reachable from all others
- a `#[TenantAware]` entity whose tenant field is not mapped
- an entity carrying neither attribute

Exit code is non-zero when errors are found, so it drops straight into CI. It reads ORM mapping metadata rather than a live database, so no provisioned schema is needed. `--strict` also fails on warnings.

`tenant:each` runs any console command once per tenant, inside that tenant's scope:

```
# migrate every active tenant
bin/console tenant:each --all -- 'doctrine:migrations:migrate --no-interaction'

# just two of them
bin/console tenant:each --tenant=acme --tenant=globex -- 'app:backfill'

# see what would run
bin/console tenant:each --all --dry-run -- 'app:backfill'
```

It is generic rather than a `tenant:migrate` command so it also covers cache warming, fixtures and backfills — and so applications on the discriminator strategy are not forced to install `doctrine/migrations`.

Three behaviours are deliberate:

- **There is no default selection.** `--tenant` or `--all` is required. A command that silently assumes "all tenants" is how something meant for one tenant reaches every tenant.
- **An unknown identifier aborts before any work starts**, so a typo cannot quietly mean "one fewer tenant migrated".
- **A failing tenant does not stop the rest.** One broken migration must not leave the remaining tenants unmigrated. Failures are collected, reported per tenant, and the exit code is non-zero. Pass `--stop-on-failure` to opt out.

Extension points
----------------

[](#extension-points)

### A custom resolver

[](#a-custom-resolver)

Tag any `TenantResolverInterface` with `multi_tenancy.resolver`. Higher priority runs first; the first resolver returning non-null wins.

```
final class PathPrefixTenantResolver implements TenantResolverInterface
{
    public function __construct(private TenantRegistryInterface $registry) {}

    public function supports(ResolutionContext $context): bool
    {
        // No Request outside HTTP — console commands and message handlers
        // share this interface, so never assume one exists.
        return $context->isHttp();
    }

    public function resolve(ResolutionContext $context): ?TenantInterface
    {
        if (!preg_match('#^/t/([a-z0-9-]+)#', $context->request->getPathInfo(), $m)) {
            return null;   // "not applicable" — try the next resolver
        }

        return $this->registry->findByIdentifier($m[1]);
    }
}
```

```
services:
    App\Tenant\PathPrefixTenantResolver:
        tags:
            - { name: multi_tenancy.resolver, priority: 50 }
```

Returning `null` means "not applicable, try the next one". Returning an inactive tenant aborts the chain with an exception rather than falling through — a suspended tenant must not be quietly resolved by a lower-priority path.

### A custom registry

[](#a-custom-registry)

Override the `TenantRegistryInterface` alias. The Doctrine-backed registry is the default when `registry.entity` is set; anything else (a config file, a remote service) is a matter of implementing three methods.

### A custom isolation strategy

[](#a-custom-isolation-strategy)

Implement `TenantIsolationStrategyInterface` and alias `TenantIsolationStrategyInterface` to it. **`activate()` and `deactivate()` must not be called directly** — `TenantContext::runFor()` owns the EntityManager clearing that makes a switch safe, and a strategy invoked outside it swaps the data source while leaving the identity map populated.

Known limitations
-----------------

[](#known-limitations)

- **Raw DBAL bypasses the filter entirely.** Anything built with `Connection::executeQuery()` or the DBAL query builder is unfiltered. Use the ORM, or scope such queries by hand.
- **Unique constraints must include the tenant column.** A `UNIQUE(slug)` that should be `UNIQUE(tenant_id, slug)` breaks the first time two tenants pick the same slug. `tenant:schema:audit` finds these.
- **Cross-tenant foreign keys surface as `EntityNotFoundException`** when the association is touched, far from the cause.
- **The header resolver is disabled by default.** A client-supplied header naming the tenant is a one-header privilege escalation unless stripped at the edge proxy. Enable it only behind a proxy you control.

Configuration reference
-----------------------

[](#configuration-reference)

```
multi_tenancy:
    preset: ~                        # api | web — a starting point, always overridable
    strategy: discriminator          # discriminator | database | none
    unmarked_entities: strict        # strict | allow
    database:                        # strategy: database only
        dsn_template: ~              # must contain %identifier%
        max_connections: 5
    registry:
        entity: ~                    # Doctrine entity implementing TenantInterface
        identifier_field: identifier
    resolvers:
        explicit:
            enabled: true            # --tenant, Messenger stamps, tests
        header:
            enabled: false           # see limitations
            name: X-Tenant-Id
        subdomain:
            enabled: true
            base_domain: ~           # required when enabled
    cache:
        enabled: false               # requires symfony/cache
        pools: []                    # service ids to make tenant-aware
        shared_prefix: shared        # namespace used with no tenant active
    messenger:
        enabled: false               # requires symfony/messenger
        require_stamp: true          # refuse messages with no tenant stamp
    security:
        enabled: false               # requires symfony/security-bundle
        membership_provider: ~       # service id, required when enabled
        api:
            enabled: false
            header_policy: require_match   # require_match | reject | ignore
            header_name: X-Tenant-Id
            require_claim: true      # a token with no tenant is not valid everywhere
            issuer:
                enabled: false   # validate `iss` against the tenant's realm
                provider: ~      # service id; defaults to reading TenantIssuerAware
                claim: iss
    doctrine:
        filter_name: tenant
        write_protection: true       # reject cross-tenant inserts/updates/deletes
```

Versioning
----------

[](#versioning)

Semantic versioning. Within `1.x`:

- The interfaces under `Security/`, `Resolver/`, `Tenant/` and `Isolation/` are what you implement or type against — breaking them means a major version.
- Classes marked `@internal`, and the isolation strategies' `activate()` / `deactivate()`, are not public API. Call `TenantContext::runFor()` instead.
- Adding a config key with a safe default, or a new resolver or strategy, is a minor release.

Supported: PHP 8.3–8.5, Symfony 7.4 (LTS) and 8.x, Doctrine ORM 3.x. Every combination runs in CI, including a lowest-dependency lane.

Development
-----------

[](#development)

```
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse
```

The `tests/Security/` suite is the point of the project: it pins down the cross-tenant leaks, and each control was verified by mutation — breaking the control on purpose and confirming the tests fail. If you change one of them, check the tests still fail without it.

Licence
-------

[](#licence)

MIT. See [LICENSE](LICENSE).

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance98

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity49

Maturing project, gaining track record

 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.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

9d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/42f7ccbb50ac26336f56240519756fffae416abcdc6ff44540947bd2cc941487?d=identicon)[saifulferoz](/maintainers/saifulferoz)

---

Top Contributors

[![saifulferoz](https://avatars.githubusercontent.com/u/15348453?v=4)](https://github.com/saifulferoz "saifulferoz (22 commits)")

---

Tags

symfonydoctrineisolationsaastenantmulti-tenancymultitenant

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/saifulferoz-multi-tenancy-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/saifulferoz-multi-tenancy-bundle/health.svg)](https://phpackages.com/packages/saifulferoz-multi-tenancy-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k18.3M418](/packages/easycorp-easyadmin-bundle)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k6.0M776](/packages/sylius-sylius)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M232](/packages/sulu-sulu)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

86337.5k](/packages/flow-php-flow)[pimcore/pimcore

Content &amp; Product Management Framework (CMS/PIM/E-Commerce)

3.8k3.9M534](/packages/pimcore-pimcore)[contao/core-bundle

Contao Open Source CMS

1301.7M3.0k](/packages/contao-core-bundle)

PHPackages © 2026

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