Download the PHP package saifulferoz/multi-tenancy-bundle without Composer

On this page you can find all versions of the php package saifulferoz/multi-tenancy-bundle. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package 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:

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

Status

Phase State
Tenant context, resolvers, registry done
Discriminator isolation + write protection done
DI wiring done
Multi-database isolation done
Per-tenant auth & RBAC done
API (stateless) auth done
Messenger tenant propagation done
Cache key prefixing done
Schema audit command done
Heterogeneous platform detection done
Per-tenant issuer validation (Keycloak realms) done

Everything above ships in 1.1.0.

Not yet built — see PLAN.md §8.1: 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

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

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

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:

Usage

Within a request the tenant is resolved automatically:

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

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

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:

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

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

One database per tenant.

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

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

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

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.

A membership answers five questions:

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

Application code checks permissions, never role names:

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:

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

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

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.

Then let the tenant entity carry its realm:

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:

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

Optional, off by default. Requires symfony/cache.

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:

Messenger

Optional, off by default. Requires symfony/messenger.

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:

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

Console

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

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:

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:

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:

Extension points

A custom resolver

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

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

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

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

Configuration reference

Versioning

Semantic versioning. Within 1.x:

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

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

MIT. See LICENSE.


All versions of multi-tenancy-bundle with dependencies

PHP Build Version
Package Version
Requires php Version >=8.3
doctrine/dbal Version ^4.0
doctrine/doctrine-bundle Version ^2.12 || ^3.0
doctrine/orm Version ^3.0
symfony/config Version ^7.4 || ^8.0
symfony/dependency-injection Version ^7.4 || ^8.0
symfony/event-dispatcher Version ^7.4 || ^8.0
symfony/http-foundation Version ^7.4 || ^8.0
symfony/http-kernel Version ^7.4 || ^8.0
symfony/console Version ^7.4 || ^8.0
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package saifulferoz/multi-tenancy-bundle contains the following files

Loading the files please wait ...