rasuvaeff/yii3-tenancy

Multi-tenancy core for Yii3: tenant resolution, request context, and scoping primitives

Maintainers

Package info

github.com/rasuvaeff/yii3-tenancy

pkg:composer/rasuvaeff/yii3-tenancy

Transparency log

Statistics

Installs: 28

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-04 07:58 UTC

This package is auto-updated.

Last update: 2026-07-04 08:01:59 UTC


README

Stable Version Total Downloads Build Static analysis Psalm level PHP License

Multi-tenancy core for Yii3: tenant resolution from the request (header/subdomain/path), a request-scoped CurrentTenant context, and scoping primitives. Deliberately no ORM auto-scoping magic — explicit primitives and recipes instead.

Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model. Contributors: see AGENTS.md.

Requirements

Requirement Version
PHP 8.3 – 8.5
PSR-7 / PSR-15 / PSR-17 / PSR-16 any implementation

Installation

composer require rasuvaeff/yii3-tenancy

For persistent tenant storage add the DB backend (planned: rasuvaeff/yii3-tenancy-db) or bind your own TenantProvider.

Usage

Resolution middleware

use Rasuvaeff\Yii3Tenancy\ConfigTenantProvider;
use Rasuvaeff\Yii3Tenancy\HeaderTenantResolver;
use Rasuvaeff\Yii3Tenancy\RequestCurrentTenant;
use Rasuvaeff\Yii3Tenancy\TenantResolutionMiddleware;

$middleware = new TenantResolutionMiddleware(
    resolver: new HeaderTenantResolver(),                 // X-Tenant-Id
    provider: new ConfigTenantProvider([
        'acme' => ['name' => 'Acme Inc', 'attributes' => ['plan' => 'pro']],
    ]),
    currentTenant: $requestCurrentTenant,                 // shared RequestCurrentTenant
    responseFactory: $psr17Factory,
);

Place it in the middleware pipeline before authentication — the tenant usually determines the user store. On success the tenant is published twice:

  • CurrentTenant service (constructor-inject it anywhere);
  • Tenant::class request attribute.

Unresolved/unknown key → 404; suspended tenant → 403. Both are policies (TenantPolicy::Reject | TenantPolicy::PassThrough).

Resolvers

Resolver Source Example
HeaderTenantResolver X-Tenant-Id header (configurable) X-Tenant-Id: acme
SubdomainTenantResolver first label under a configured base domain acme.example.com
PathTenantResolver first segment after a configured prefix /t/acme/dashboard
CompositeTenantResolver chain, first non-null wins header, then subdomain

Every resolver validates the extracted key against Tenant::isValidId() (/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/) and returns null on mismatch — keys taken from requests are untrusted input. Nested subdomains (a.b.example.com) and lookalike hosts (acmeexample.com) resolve to null.

Reading the current tenant

use Rasuvaeff\Yii3Tenancy\CurrentTenant;

final readonly class InvoiceService
{
    public function __construct(private CurrentTenant $currentTenant) {}

    public function create(): void
    {
        $tenantId = $this->currentTenant->get()->id;   // throws if unresolved
        $plan = $this->currentTenant->get()->attributes['plan'] ?? 'free';
    }
}

For console/test contexts where one process handles several tenants use RequestCurrentTenant::override().

Tenant-scoped cache

use Rasuvaeff\Yii3Tenancy\TenantScopedCache;

$cache = new TenantScopedCache($psr16Cache, $currentTenant);
$cache->set('report', $data);   // stored as "t.acme.report"

clear() delegates to the inner cache and wipes all tenants — PSR-16 has no prefix-scoped clear. Do not call it in tenant-scoped code paths.

DI configuration (Yii3)

Ships config/di.php + config/params.php via config-plugin. The core binds CurrentTenant, the resolvers, and the middleware. TenantProvider is deliberately not bound — exactly one source binds it: a backend package or your application:

// config/common/di/tenancy.php
use Rasuvaeff\Yii3Tenancy\ConfigTenantProvider;
use Rasuvaeff\Yii3Tenancy\TenantProvider;

return [
    TenantProvider::class => static fn (): TenantProvider => new ConfigTenantProvider([
        'acme' => ['name' => 'Acme Inc'],
    ]),
];

Override params as needed:

// config/params.php
return [
    'rasuvaeff/yii3-tenancy' => [
        'header' => 'X-Tenant-Id',
        'base_domain' => 'example.com',   // required by SubdomainTenantResolver
        'path_prefix' => '/t',
        'resolvers' => [
            \Rasuvaeff\Yii3Tenancy\HeaderTenantResolver::class,
            \Rasuvaeff\Yii3Tenancy\SubdomainTenantResolver::class,
        ],
        'unresolved_policy' => 'reject',      // or 'passthrough'
        'suspended_policy' => 'reject',
    ],
];

Recipes: wiring into the rasuvaeff/* ecosystem

// feature flags: tenant-aware FlagContext
FlagContext::class => static fn (CurrentTenant $t): FlagContext =>
    new FlagContext(tenantId: $t->find()?->id),

// clickhouse-toolkit: mandatory tenant filter
$builder->withMandatoryFilter(column: 'tenant_id', value: $currentTenant->get()->id);

// settings / feature flags: tenant-isolated cache layer
CacheInterface::class => static fn (CacheInterface $inner, CurrentTenant $t): CacheInterface =>
    new TenantScopedCache($inner, $t),

Components

Tenant

Property Type Description
id string validated: /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
name string optional display name
status TenantStatus Active (default) / Suspended
attributes array<string, mixed> free-form tenant metadata

CurrentTenant / RequestCurrentTenant

Readers depend on the CurrentTenant interface (get(), find(), isResolved()); the middleware depends on the concrete RequestCurrentTenant (set() once per request, override() for console/tests).

TenantResolutionMiddleware

Parameter Type Default Description
resolver TenantResolver key extraction
provider TenantProvider key → Tenant lookup
currentTenant RequestCurrentTenant publication target
responseFactory ResponseFactoryInterface builds 404/403
unresolvedPolicy TenantPolicy Reject unresolved/unknown key
suspendedPolicy TenantPolicy Reject suspended tenant

Security

  • Tenant keys extracted from requests are untrusted input — every resolver validates against a strict whitelist pattern before lookup.
  • Subdomain resolution matches only against the configured base domain, never the raw Host value alone; nested labels are rejected.
  • There is no implicit "default tenant" fallback — unresolved requests are rejected unless you explicitly opt into passthrough.
  • The package performs no I/O, SQL, or shell access itself.

Examples

See examples/ for a runnable script.

Script Shows Needs server?
resolve-tenant.php Resolution, request attribute, 404/403 policies no

Development

No PHP/Composer on the host — run in Docker via the composer:2 image:

docker run --rm -v "$PWD":/app -w /app composer:2 composer build

Or with Make: make build, make cs-fix, make psalm, make test.

License

BSD-3-Clause. See LICENSE.md.