loongs / saas
loong-swoole multi-tenancy (SaaS) on top of loongs/orm — coroutine-local tenant scopes, tenant registry / resolvers, one bounded connection pool per tenant; calls without a tenant keep using the framework / ORM pools
Requires
- php: ^8.4
- loongs/orm: dev-main
Requires (Dev)
None
Suggests
- ext-swoole: Coroutine-local tenant scopes (Tenancy::run / Tenancy::go) and waiting for a free pooled connection
- loongs/framework: Calls without a tenant use the framework's PDOPool (db()); tenant pool settings from config('database.tenant_pool')
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-08 02:56:33 UTC
README
SaaS multi-tenancy for loongs/orm on the loong-swoole stack (PHP 8.4,
Swoole coroutines): coroutine-local tenant scopes, a tenant registry / resolver, and one connection pool per
tenant, separate from the framework and ORM pools. Built only on loongs/orm's public extension points
(Orm::resolveDefaultUsing(), Orm::addLeaseProvider()); loongs/orm does not depend on this package.
composer require loongs/saas:dev-main # pulls loongs/orm; in the monorepo via a path repo (see "Development")
Layout
src/
Tenancy.php facade: resolver, run()/central()/go(), current tenant, connection/table/acquire/using/transaction, pool
Tenant.php immutable tenant: id + connection (name / config array / DSN / ConnectionConfig) + attributes
TenantResolver.php id → Tenant lookup (interface) Resolver/{ArrayTenantResolver,CallbackTenantResolver}.php
TenantPool.php per-tenant pools (loongs/orm LeaseProvider over a ConnectionPool, one bucket per tenant)
TenantPoolConfig.php size / max_tenants / idle_seconds / ttl / wait_timeout / validate
TenantScope.php coroutine-local scope (internal)
Exceptions/{TenantNotFoundException,NoTenantException}.php
tests/ saas_test.php bootstrap.php models.php real-MySQL tenant isolation suite (composer test)
Quick start
use Loongs\Saas\Tenancy; use Loongs\Saas\Tenant; // 1. how tenant ids are found (once per worker): array, closure or a TenantResolver Tenancy::resolveUsing(fn (string $id): ?Tenant => ($row = Tenancy::central(fn () => Account::where('slug', $id)->first())) ? Tenant::make($id, ['driver' => 'mysql', 'host' => $row->db_host, 'database' => $row->db_name, 'username' => $row->db_user, 'password' => $row->db_pass, 'prefix' => $row->prefix ?? ''], ['plan' => $row->plan]) : null); // 2. per request: scope the whole handler (tenant middleware) return Tenancy::run($tenantId, function (Tenant $t) use ($request) { $user = User::where('email', $request->post('email'))->firstOrFail(); // → tenant DB, tenant pool $user->posts()->create(['title' => 'hi']); // → tenant DB Plan::find($user->plan_id); // Plan declares $connection = 'central' → central DB (framework pool) Tenancy::central(fn () => Audit::create([...])); // no tenant: default connection return Orm::transaction(fn () => Invoice::create([...])); // tenant transaction, one connection }); // 3. explicit, no scope User::on(Tenancy::config('acme'))->count(); Tenancy::table('invoices', 'acme')->where('paid', 0)->count(); // 4. background work that must keep the tenant (plain go() / Coroutine::create start with NO tenant) Tenancy::go(fn () => Audit::create([...]));
A tenant may be given as: a Tenant; a tenant id (looked up by the resolver; without one / not found, a
configured connection of that name); a URL / PDO DSN, config array or ConnectionConfig (id = database name, or
database/prefix). Unknown ids throw TenantNotFoundException; Tenancy::config() / connection() / … with
null outside a scope throw NoTenantException.
Guarantees
- Per-call connection, nothing on the model. Models / handles keep a
ConnectionConfig, never a PDO. A model loaded or first saved on tenant A still saves / deletes / loads relations on A inside a scope of tenant B; models with a declared connection (protected ?string $connection = 'central') ignore the scope. - Scope is coroutine-local. Stored in the coroutine context: private to the coroutine, nestable, restored when
the callback returns or throws, not inherited by child coroutines (use
Tenancy::go()). Outside coroutines it is a plain static stack with the same semantics. - Everything pooled. Calls without a tenant are untouched by this package: named connections use the
framework
PDOPool(when booted) and anything else the ORM pool. Every tenant gets its own bounded pool (TenantPool, a bucket keyed by the tenant's full config fingerprint, credentials included): a connection opened for one tenant can never be handed to another, and tenant traffic never enters the framework / ORM pools. A tenant defined by a connection name is copied to an ad-hoc config, so the name keeps its framework pool for non-tenant use. - Returned manually or automatically. Per statement / transaction automatically; manually with
Tenancy::acquire()+release()(orusing()); on exceptions the connection is rolled back and returned; leaked leases are reclaimed at coroutine end (with a warning). - Broken connections discarded. Lost / killed / out-of-sync connections (and failed rollbacks) are closed, never
reused; the pool opens replacements. Checkout validates
SELECT DATABASE()(aUSE other_dbcan not leak). - Process-wide state is configuration only (resolver, pool settings, tenant registrations, the pools); no request state outside the coroutine context.
API
| call | |
|---|---|
Tenancy::resolveUsing($resolver) |
TenantResolver, Closure(string $id): Tenant|spec|null, or ['id' => Tenant|spec]; null removes it |
Tenancy::run($tenant, fn (Tenant $t) => …) |
run inside a tenant scope |
Tenancy::central(fn () => …) |
run with no tenant (default / central connection) |
Tenancy::go(fn () => …) |
Coroutine::create carrying the current scope |
Tenancy::current() / id() / active() / connectionConfig() |
current Tenant, its id, in a scope?, its ConnectionConfig (null outside) |
Tenancy::tenant($t) / find($id) |
normalise to a Tenant / resolver lookup only |
Tenancy::config($t = null) |
registered ConnectionConfig of a tenant (for Model::on(), Orm::table() …) |
Tenancy::connection($t = null) / table($table, $t = null) |
connection handle / query builder on a tenant |
Tenancy::acquire($t = null) / using($t, fn (Connection $c) => …) |
manual lease of a tenant connection (release(); using always releases) |
Tenancy::transaction(fn (Connection $c) => …, $t = null, $attempts = 1) |
transaction on a tenant |
Tenancy::configurePool([...]) / pool() / resolvePoolConfig() |
tenant pool settings / the worker's TenantPool |
Tenancy::poolStats($t = null) |
totals (tenants, registered, open, active, idle, waiting, created, closed, hits, discarded, waits, timeouts, evicted_tenants) or per tenant |
Tenancy::forget($t) |
offboard a tenant from this worker (pool retired, config no longer routed) |
Tenancy::install() / uninstall() / installed() / reset() |
hook into / out of the Orm resolver (done lazily on first use; reset() for tests) |
Plain Orm::* and models keep working inside a scope: Orm::table(), Orm::connection(), Orm::transaction(),
Orm::acquire() and models without a bound / declared connection use the current tenant; Orm::prefix(),
Orm::tableName(), Orm::config() report the tenant's prefix / config.
Tenant pool settings
Tenancy::configurePool([...]) → config('database.tenant_pool') → TENANT_POOL_* env → defaults:
| key | env | default | meaning |
|---|---|---|---|
size |
TENANT_POOL_SIZE |
8 | max open connections per tenant (checked out + idle) |
max_tenants |
TENANT_POOL_MAX_TENANTS |
64 | tenants keeping connections open; the least recently used idle one is closed |
idle_seconds |
TENANT_POOL_IDLE_SECONDS |
60 | idle connections older than this are closed |
ttl |
TENANT_POOL_TTL |
600 | connections older than this are closed (0 = no limit) |
wait_timeout |
TENANT_POOL_WAIT_TIMEOUT |
3 | seconds to wait for a free connection, then PoolExhaustedException (-1 = forever) |
validate |
TENANT_POOL_VALIDATE |
true | checkout check: not in a transaction + SELECT DATABASE() matches |
// config/database.php (the same key the tenant-aware loongs/orm used, so existing configs keep working) 'tenant_pool' => ['size' => 8, 'max_tenants' => 64, 'idle_seconds' => 60, 'ttl' => 600, 'wait_timeout' => 3],
Sizing: worst case per worker ≈ max_tenants × size tenant connections + the framework pools + the ORM pool — keep
it below MySQL max_connections / workers. max_tenants is soft (a new tenant still gets a pool when every other
tenant has connections checked out). Tenants whose configs are identical share one pool. Changing a registered
tenant's connection (credential rotation, moved database) retires its old pool: idle connections close now,
checked-out ones when they come back.
Upgrading from the tenant-aware loongs/orm (breaking)
| before (loongs/orm) | now (loongs/saas) |
|---|---|
Orm::tenant($spec, fn () => …) |
Tenancy::run($spec, fn () => …) — $spec may still be a config array / DSN / ConnectionConfig; or register ids with resolveUsing() and pass the id |
Orm::currentTenant() (?ConnectionConfig) |
Tenancy::connectionConfig(); also Tenancy::current() (?Tenant), id(), active() |
Orm::go(fn () => …) |
Tenancy::go(fn () => …) |
Loongs\Orm\Connection\TenantPool (Orm::pool()) |
Loongs\Saas\TenantPool (Tenancy::pool()); Orm::pool() is now a generic ConnectionPool for non-tenant ad-hoc configs |
Orm::configurePool(['max_tenants' => …]) for tenants |
Tenancy::configurePool(['max_tenants' => …]) |
config('database.tenant_pool') read by the ORM |
read by loongs/saas (the ORM pool now reads database.orm_pool, key max_pools) |
env ORM_POOL_* for tenants |
TENANT_POOL_* |
Orm::poolStats() (tenants, evicted_tenants) |
Tenancy::poolStats() (same keys) — Orm::poolStats() now reports pools, evicted_pools |
tenant leases: LeaseSource::Pool |
LeaseSource::Provider |
User::on($tenantArray) |
unchanged; served by the tenant pool once that tenant is registered (Tenancy::run / config / tenant), else by the ORM pool |
Everything else (models, relations, events, Orm::transaction(), Orm::acquire() / using(), prefixes,
Model::on()) is unchanged. Packages that "follow the tenant scope" by using the default connection (e.g.
loongs/oauth new OrmStorage()) follow Tenancy::run() without changes.
Development
cd composer/saas && composer test # = php -d disable_functions= tests/saas_test.php cli && … co # needs MySQL; credentials from DB_SOCKET / DB_USERNAME / DB_PASSWORD or LOONGS_TEST_ENV=/path/to/.env; # throwaway databases loongs_saas_* are dropped at the end. Autoload: vendor/autoload.php after `composer install`, # else the sibling ../orm, ../helper, ../framework sources (monorepo composer/ directory).
In the loong-swoole monorepo link it like the other packages (path repo in server/composer.dev.json).
License: MIT