nvl / tenancy
Explicit tenant context and isolation contracts for Laravel applications
Requires
- php: ^8.4
- ext-filter: *
- ext-mbstring: *
- ext-pdo: *
- laravel/framework: ^12.0 || ^13.0
- nvl/core: ^6.0
- symfony/http-foundation: ^7.2 || ^8.0
- symfony/http-kernel: ^7.2 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- symfony/process: ^7.2 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Quickstart
composer require nvl/tenancy:^6.0 php artisan nvl:install tenancy --dry-run php artisan nvl:install tenancy
Required NVL dependencies: nvl/core (^6.0). Explicitly configure and adopt tenant resources after reviewing the tenancy guide. Use an active persisted native TenantId; no request can choose its own ownership boundary.
Review the published common config, select one migration owner, and run schema preflight before existing-table upgrades. The installer does not enable features or run migrations. Follow the detailed installation and capability sections below before invoking a storage/provider operation.
Inject Nvl\Support\Tenancy\Contracts\TenantDirectory in a host service. After supplying the trusted inputs described above, the first public call is:
use Nvl\Support\Tenancy\Contracts\TenantDirectory; /** @var TenantDirectory $capability */ $result = $capability->find($tenantId);
Use the event catalog and Testing your app below. The suite getting-started guide provides a complete Comments host fixture; package archives retain their own local references.
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/tenancy:^6.0 |
| Module identifier | nvl/tenancy |
| PHP namespace | Nvl\Tenancy |
| Service provider | Nvl\Tenancy\Providers\TenancyServiceProvider |
| Configuration | config/nvl-tenancy.php |
Purpose
nvl/tenancy provides the deployment configuration, immutable context values,
adapter contracts, and fail-closed error vocabulary used by tenant-aware NVL
packages on Laravel 12–13 and PHP 8.4.
The package is inert by default. Its provider registers a scoped disabled
context, the default migration switch loads no schema, no global middleware is
attached, and unadopted package queries remain unchanged while nvl-tenancy.enabled is
false. An adopted resource always checks its persisted marker, including when
the feature is disabled. Queue guards are registered without capturing an application or tenant
and act only during a recovery lease.
Requirements and installation
composer require nvl/tenancy:^6.0
The package is inert after installation. Publish configuration only when the application needs to change its defaults, and publish skills only when its agents need Tenancy guidance:
php artisan vendor:publish --tag=nvl-tenancy-translations php artisan vendor:publish --tag=nvl-tenancy-config php artisan vendor:publish --tag=nvl-tenancy-skills
Do not publish nvl-tenancy-migrations as a routine installation step. Published
migrations become application migrations and will run on the next
php artisan migrate, even while nvl-tenancy.migrations.enabled is false.
Laravel auto-discovers Nvl\Tenancy\Providers\TenancyServiceProvider. The
package requires nvl/core from the 2.x package line. NVL Auth
is not required. Filterable is an optional host dependency, installed explicitly
when the host uses its query filters. The Suite install remains supported.
Foundation distribution contract
TenantConsumerContractTest.php verifies inert provider behavior and TypeScript
source registration. The root TenancyConsumerWorkflowTest.php creates actual
Composer archives, resolves a fresh offline runtime dependency closure, and boots
an independent Laravel consumer with cached configuration and Doctor. Only the
Tenancy, Support and Data NVL packages are admitted. An explicit Filterable profile
adds its archive and reuses neutral host fixtures to prove OR/relation predicates
preserve an existing tenant restriction and mutation projection omits ownership.
Consumer entry points are the documented contracts, immutable values, lifecycle
Actions, and Core's Nvl\Support\Tenancy\Contracts\TenantRunner and
Nvl\Support\Tenancy\Contracts\TenantBoundary contracts. Enforcing runtime
implementations and TenantAdoptionCoordinator are package infrastructure.
Registration/adoption adapter seams belong to package implementers. Concrete
scoped context, queue handlers/carriers, maintenance leases and adoption stores
are infrastructure; their presence in the signature inventory does not make them
consumer bypass APIs. Do not write the package-owned Tenant model directly.
This is a foundation prerequisite. Auth membership, tenant-owned Settings and other domain integrations are not released by this proof. TypeScript discovery registers the package source; it adds no ownership mutation DTO.
Configuration
The shipped config/nvl-tenancy.php selects the first-release shared-database
strategy and keeps activation and optional migrations disabled. Configuration
contains deployment-level scalars, literal arrays, and adapter class strings;
closures and current tenant or actor values are rejected.
The application profile defaults registered mutable resource families to
tenant. Registered families may explicitly choose tenant or platform when
their code-owned capabilities support that mode. Unknown families, contradictory
parent modes, and incompatible declared family dependencies fail after provider
registration completes. Family overrides apply to mutable roots; fixed platform
vocabulary in the same family stays platform-owned and does not create a mutable
ownership conflict. A family consisting only of fixed platform definitions cannot
be reclassified as tenant-owned. Sharing supports only none and copy for Media, Metafields, and
Templates, and does not create a shared mutable resource mode.
Host adapters may implement TenantDirectory, TenantMembershipAccess,
PlatformAccess, TenantHttpResolver, or TenantSiteResolver. Choose either a
configured class string or a host container binding for one contract. Supplying
both is allowed for matching, inspectable class bindings. Genuinely conflicting
or opaque simultaneous bindings are rejected. Validation never constructs the
adapter. Missing membership and platform adapters deny access; the package
directory fallback fails with TenantSchemaNotReady until its optional store
exists. Selecting a host directory requires a host adapter.
Core storage is a separate opt-in migration set. Set
nvl-tenancy.migrations.enabled=true to register it with Laravel migrations, or
publish nvl-tenancy-migrations when the application owns the migration copy. The
set runs on nvl-tenancy.connection and creates the tenant directory, installation
state, privileged-operation audit, adoption runs, and reviewed adoption mappings.
Feature activation never registers this path by itself. A package-owned effective
directory receives tenant foreign keys; an effective host directory keeps its
application tenant identifiers as verified references.
For an application-owned migration copy, keep
nvl-tenancy.migrations.enabled=false, publish once with
php artisan vendor:publish --tag=nvl-tenancy-migrations, review the files, then
run php artisan migrate. For vendor-owned migrations, enable the setting
before running php artisan migrate and leave the tag unpublished. Never run
both sources.
Runtime compatibility and readiness
Provider selection, nvl-tenancy.enabled, ownership configuration, and persisted
adoption readiness are separate facts. The standalone package exposes
nvl:tenancy:doctor --json for read-only storage probes, registered resource
inspection, installation state, and interrupted runs. Its configuration
inspection does not probe schema and reports not-probed until Doctor checks
storage. Database errors fail diagnostics; they never mean that a database is
unadopted.
Loaded stateful NVL runtime providers require their real code-owned resource family and adoption adapter registrations before tenant activation. CSV requires an adoption adapter, which may legitimately expose zero resources; no artificial CSV marker or family is required. Neutral Support/Data/Filterable/Primitives stay outside this check. Translatable is owner-driven: it owns neither production schema nor an adoption adapter; its owners must supply declarations and guards. Its lack of roots is not evidence of tenant isolation. Composer package presence without a loaded runtime provider does not activate a family.
Incomplete integrations remain bootable in Unresolved context for diagnostics
and narrowly admitted platform bootstrap. They are readiness errors while
Tenancy is enabled, and TenantOwnershipConfiguration::assertReady() rejects
ordinary tenant entry, tenant maintenance, boundary use (including host context
implementations), and adoption activation. Structural family, parent, and
dependency contradictions still fail after provider registration. Platform
provisioning remains separately authorized. There is no configuration bypass
list, fabricated default ownership for unregistered families, or implicit legacy
Settings tenant access. Future integrations must ship their actual guards and
adapters; this foundation alone does not make those packages tenant-safe.
Resolver values are null or a class string, never chains/lists. Nested maps use
shared deep merging and ordinary configuration lists replace atomically. Host
binding precedence and conflicting explicit class configuration are validated
without constructing request adapters. Unknown keys and invalid values produce
bounded diagnostic labels.
Choose one migration owner
For package-owned migrations, set nvl-tenancy.migrations.enabled=true and do not publish
nvl-tenancy-migrations. For application-owned migrations, publish with
php artisan vendor:publish --tag=nvl-tenancy-migrations and keep
nvl-tenancy.migrations.enabled=false. Never run both migration copies. Publication
uses Laravel's timestamp-aware migration API; released core migration files are
immutable. Both modes are independent of the feature flag and resource adoption.
Disabled compatibility
Resolve Nvl\Tenancy\Contracts\TenantContext to inspect the immutable current
snapshot. Disabled installations return TenantContextMode::Disabled and need
no Tenancy tables. Ordinary tenant execution and tenant maintenance reject
disabled configuration. Explicitly authorized, durably audited platform work can
provision package directory entries before tenant activation. Enabling the feature
flag alone is not a resource-adoption or isolation path.
ProvisionTenantAction::execute(string, PlatformOperation) creates an active
package-directory tenant with a server-generated UUID after authorization and a
durable audit. Names are trimmed and must contain 1–255 characters.
ChangeTenantStatusAction::execute(TenantId, TenantStatus, PlatformOperation)
locks the canonical row before changing its lifecycle state. Suspended and
deleted tenants stop ordinary admission on the next directory check. Both actions
reject an effective host directory because host storage writes remain application
owned.
Scoped execution and admission
TenantRunner::run(TenantId, Closure) is a trusted application boundary. It
checks the current directory status and enters an active tenant for the callback.
TenantRunner::platform(PlatformOperation, Closure) requires explicit host
PlatformAccess authorization and a durable SQL audit before the callback. The
operation's actor and purpose fields must each contain 1–255 characters.
Privileged entry fails when the audit store is absent or its connection already
has an open transaction. Audit writes precede callback-owned transactions.
The TenantContext interface remains read-only. Native runners require the
native scoped implementation and fail clearly for incompatible host context
overrides. The internal concrete state, maintenance lease, and queue guard are
not consumer APIs. Retained runners resolve context and host adapters for each
current worker scope; registries keep class names, never requests or actors.
Register integration classes through
TenantContextParticipants::register(TenantContextParticipant::class). Each
participant's enter(TenantContextSnapshot) returns its restoration closure.
An enter failure must undo that participant's own partial state. Entered
participants unwind in reverse order. Cleanup failure is reported, preserves an
original operation exception, and revokes the scope until Laravel starts a new
application scope.
Every resolved Laravel database connection participates in context lifecycle checks, including connections first opened by the callback. A transaction blocks a different tenant or mode; balanced reentry to the same tenant is allowed. Callback-opened transaction levels must be closed before return. Leaked levels are rolled back and invalidate the scope; closing a caller-owned level reports corruption. A caller's already committed transaction cannot be undone. Write compatibility separately compares actual Laravel connection objects: matching DSNs under different names do not establish shared transactions.
Add RequireTenantMembership or ResolvePublicTenant explicitly to host routes.
The provider places admission before SubstituteBindings, after authentication
for membership routes, and does not change central identity routes. Host
TenantHttpResolver implementations select candidates and must reconcile all
supported route/header selectors and verified host mappings; selection never
grants membership. No universal header or domain convention is assumed.
Public TenantSiteResolver implementations must return a verified
TenantSiteContext. A verified serving alias may differ from its canonical
origin. The middleware stores this immutable value on the current request;
resolving it requires agreement with the active tenant. Unknown, suspended and
deleted public tenants receive the same not-found response. Browser Origin
and Referer values do not establish this mapping.
Registered resource ownership
Packages register immutable TenantResourceDefinition values through
TenantResourceRegistry::register(). A key identifies one concrete model and
code-owned root, inherited, or fixed-platform policy. Identical registrations
are idempotent; conflicting keys/models and declared parent cycles fail.
TenantBoundary::query() verifies installation state and wraps existing caller
conditions before adding qualified ownership predicates. Tenant mode selects the
current tenant; platform mode selects only nullable platform partitions with
ownership_key=platform. Mixed tenant rows require
ownership_key=tenant:<uuid>, using their canonical tenant UUID so portable unique
indexes retain a distinct partition for each tenant.
Tenant-only roots deny platform access. Fixed platform vocabulary requires its
own package reader. The underlying SQL builder must use the registered canonical
connection and table, including during disabled compatibility checks; aliased
root tables and unions are rejected because a single predicate cannot safely
cover them. Soft-delete and other Eloquent scopes remain in effect.
assertRecord() fetches only persisted ownership facts using the registered
model's validated connection. Dirty tenant IDs, keys, foreign keys, and retained
relations cannot supply ownership evidence. Inherited predicates and record checks
follow the canonical parent and reject unknown owners, cycles, or incompatible
connections. Polymorphic effective modes derive recursively from all allowlisted
parents; conflicting modes, missing parents and cycles fail configuration
validation. This check does not lock business content: package writers must
reload and lock records under the tenant predicate before mutation, and validate
again immediately before external side effects.
attributes() generates root ownership fields; children derive fields from their
canonical parent. key() returns the legacy identity only for disabled,
unadopted resources. Otherwise it hashes the JSON tuple of effective connection,
resource key, context mode, tenant ID, and caller identity under nvl:tenant:.
Every enabled boundary operation rechecks current directory status. Only the
exact synchronous maintenance lease admits suspended/deleted tenants.
Internal integration and adoption seams
These are package infrastructure, not consumer bypass APIs:
TenantOwnershipConfiguration::requireCompatible(family, dependency)declares a family dependency whose mutable ownership modes must match; fixed vocabulary is excluded from mode-split checks. Core imports no domain package to infer these edges.registerParentResolver(resource, resolverClass)registers a class implementingTenantParentResolver::types(): array<string, class-string<Model>>. Its map comes from the owning package's allowlist and maps persisted morph aliases to concrete models. It must be deterministic and free of tenant/request state. Every allowed model also needs a canonical resource registration. Missing, unknown, or disallowed types deny access before persisted morph types can instantiate classes. Merely registering a model globally never allowlists it.TenantOwnershipConfiguration::fingerprint(resource)is SHA-256 over canonical JSON format version 1: strategy/profile, effective core connection, configured directory driver/adapter and effective adapter class, then the sorted resource ownership closure. Each definition includes family/model/kind, parent/relation, catalog/mixed flags, effective mode, table/connection, fixed ownership columns and the mixed discriminator format (platform|tenant:<uuid>), parent resolver/type map, and sorted declared family dependencies. Parent and dependency definitions are recursively included. Unrelated resources are not.hash(list<string> resources)hashes the sorted selected resource-to-fingerprint map for an adoption run. Markers store the individual fingerprint. Neither hash includes feature/migration flags, current scope/status/actor, access/resolver configuration, or sharing policy.TenantInstallationState::assertUsable(resource)probes the resource's actual storage connection, never a newly configured empty core store. It performs one schema probe and, when present, one marker-set read per connection object per scoped worker generation. Database errors propagate. Enabled missing, prepared, incompatible, or disabled-but-adopted resources fail closed.invalidate()is for the authorized adoption coordinator after schema changes; other processes must be drained/restarted. Tenant status is never cached with these markers.
F4 tests alone seed real adoption-run and marker rows to verify this guard before the coordinator exists. Downstream integrations must use the actual coordinator and their package adoption adapters.
Synchronous tenant recovery
TenantMaintenanceRunner::run(TenantId, PlatformOperation, Closure) admits one
existing active, suspended or deleted tenant only while actual Laravel maintenance
mode is active. It requires authorization, durable audit, and no open transaction
on any resolved connection before granting a tenant-specific internal lease.
The lease is revoked in finally, cannot switch tenants or enter platform mode,
and does not remove resource ownership predicates.
Normal Bus/Dispatchable queue dispatch is rejected before publication. Direct
queue payload creation is also guarded. A retained sync queue with afterCommit
may defer that rejection until commit attempts payload creation while the lease
is active. Callback-leaked transactions are rolled back before leaving the lease,
so their deferred callbacks cannot survive recovery. No lease is added to job
metadata. Native Bus afterResponse deferral is temporarily disabled during the
lease so Dispatchable calls and retained dispatchers reach the active queue guard
immediately. The exact previous host deferral setting is restored on every exit.
Maintenance requires the native Laravel dispatcher; custom dispatcher types fail
before callback entry because their deferral behavior cannot be fenced safely.
Retained native deferred/background queues and generic defer() scheduling are
also rejected before a callback is appended. Existing host deferred callbacks and
their collection binding remain intact; unused configured drivers do not block
maintenance. Host queue managers, dispatcher instances, and payload callbacks are
preserved.
The opt-in core migration now supplies the real SQL directory and audit stores used by these boundaries. It does not adopt any domain package or make its queries tenant-safe; each integration still requires its own later resource schema, predicates, writes, adoption adapter, diagnostics, and acceptance tests.
Explicit resumable adoption
Each participating package registers its TenantAdoptionAdapter class with
TenantAdoptionRegistry::register(package, adapter). The coordinator resolves
canonical parent/family dependency closure, checks that every participating model
uses the same Laravel Connection object as the core store, and orders adapter
work before any schema transition. An adapter may legitimately return no resources
(for example a manifest-only integration); it still has immutable run input and
persisted phase checkpoints. Registration does not make any downstream package
tenant-safe by itself.
Reviewed mapping input
The command streams JSONL. Every line has resource, string record_id, canonical
UUID tenant_id, and optional metadata:
{"resource":"example.records","record_id":"record-123","tenant_id":"11111111-1111-4111-8111-111111111111","metadata":{}}
Resource and record IDs are 1–191 characters. Duplicate or conflicting assignments
fail. Mapping tenants must exist and be active in the effective directory.
Metadata is canonical bounded JSON (16 KiB, depth 16, 2048 values); it participates
in the immutable mapping hash. Adapters accepting nonempty metadata also implement
TenantAdoptionMetadataValidator::validateAssignment(TenantAssignment): void and
reject unknown fields, credentials, row payloads and invalid package references.
An adapter without this optional interface accepts only empty metadata. Nested
reviewed destination IDs are allowed; core does not assume every mapped/destination
record already exists. The owning package validates those semantics before prepare.
Adapters read owners using TenantAdoptionMappings::tenantFor(plan, resource, recordId), metadata using metadataFor(...), and stable mapping batches using
assignments(plan, resource, afterRecordId, limit). Unmapped existing roots fail;
packages may derive children from validated canonical parents. Both mapping-read
and backfill limits are 1–10000. Each callback returns an advancing opaque cursor
or null for completion; processed counts cannot exceed the requested limit.
Operator procedure
-
Take a consistent backup of the affected database, package-owned split ledgers, and external assets. Rehearse restoring it before the maintenance window.
-
Drain HTTP traffic, queue workers and scheduled jobs, then enable actual Laravel maintenance mode. Install the opt-in core schema explicitly. Use a host
PlatformAccessadapter that authenticates and authorizes the CLI operator;--actor-type,--actor-idand--purposeidentify the audit, not a privilege. -
Before schemas become prepared, use a validated maintenance bootstrap with
NVL_SETTINGS_CONFIG_OVERRIDES=false(nvl-settings.overrides.enabled=false) so Settings cannot derive bootstrap config by reading blocked resources. Build that config cache before preparing, or use an isolated uncached maintenance environment; changing an environment variable does not replace an already cached true value. Keep ordinary request/job guards enabled. -
Inspect the read-only report with
php artisan nvl:tenancy:doctor --json. Suite-compatible--format=text|jsonand--strictare also supported. Missing enabled schema or incompatible resources are errors; interrupted runs are warnings that fail in strict mode. Doctor creates no runs, audits or schema. -
Prepare the reviewed graph and retain the printed run UUID. For example:
php artisan nvl:tenancy:adopt prepare --packages=example --mapping=/secure/reviewed.jsonl --actor-type=operator --actor-id=ops-1 --purpose="reviewed adoption" php artisan nvl:tenancy:adopt backfill --run=RUN_UUID --limit=500 --actor-type=operator --actor-id=ops-1 --purpose="reviewed adoption" php artisan nvl:tenancy:adopt verify --run=RUN_UUID php artisan nvl:tenancy:adopt activate --run=RUN_UUID --actor-type=operator --actor-id=ops-1 --purpose="reviewed adoption"
Repeat backfill until it reports completion. Preparation captures all prepared markers before adapter DDL; partial work therefore remains blocked. Each mutating invocation has fresh authorization, maintenance checks and a durable audit outside callback-owned transactions. Do not wrap the coordinator in an outer transaction.
-
Activation reruns whole-graph verification, applies every adapter's idempotent final constraints, then commits the active run and all markers together. Restore the validated bootstrap configuration, rebuild configuration caches and restart drained processes before reopening traffic. Local probe invalidation cannot update another process's already loaded cache.
Recovery and adapter obligations
resume(runId) reloads the original immutable packages, mapping and configuration.
verify(plan) is read-only and persists no authorization-free verification flag.
Activation always rechecks actual storage after its fresh authorization. An
interrupted prepare, batch or DDL phase retries idempotently; successful DDL may
already be committed even though no active marker exists. Adapters must revalidate
actual schema, use stable bounded primary-key batches, preserve existing canonical
owners, validate parents and tenant status, and install their final constraints.
This API is not a cross-tenant transfer facility.
Source-data repair consistent with the reviewed mapping may resume the same run. Mappings and hashes cannot be rewritten. A prepared graph cannot be superseded; wrong reviewed input requires the rehearsed pre-adoption backup recovery and a new review. A fully active selected graph may enter a new explicitly authorized adoption for supported structural evolution: new prepared markers replace its active markers while prior run/mapping history remains intact. Package adapters must reject unsupported mode changes and ownership transfers. Core never invents splits, copies, memberships or owner flags.
A connection-wide PostgreSQL session advisory lock, MySQL/MariaDB GET_LOCK, or
local SQLite file lock serializes independent processes across audits, DDL and
checkpoints. Contention fails closed for retry; reconnect/session replacement is
forbidden until the phase ends. Reads use the locked primary session, with host
replica routing restored afterward. Unsupported engines and SQLite network filesystems
are unsupported; in-memory SQLite storage is inherently process-local. The lock
is not an ordinary-write fence: draining processes and maintaining the application
maintenance window are mandatory.
The internal adoption scope contains only the run, registered adapter and phase.
It grants no ordinary boundary access or tenant recovery lease. Synchronous
canonical package SQL/DDL is the adapter's authority; queue, after-response,
deferred and background publication is fenced, with host dispatch behavior restored
in finally. Leaked callback transactions are rolled back before the scope clears
so after-commit publication cannot escape. Tests use TenancyDatabaseTestCase directly from Testbench with
DatabaseMigrations, real core migrations and real adapters, never a wrapping
RefreshDatabase transaction or manually fabricated active markers.
Security
Tenant IDs are canonical UUIDs. A missing tenant scope fails closed through a typed exception. The adoption coordinator only executes explicitly registered package adapters. Normal queue context propagation/restoration remains future foundation work and must not be inferred from provider registration or synchronous adoption/recovery.
Development/verification
Package checks use the local Suite dependency graph and run Pest serially:
composer test
composer analyse
composer format
composer validate:distribution
The engine-aware TenantSupportedDatabaseTest runs real prefixed core schema,
package/host directory constraints, interrupted coordinator adoption, final DDL,
and consumer-owned up/down migrations on PostgreSQL 17, MySQL 8.4, MariaDB 12.3,
and SQLite. The selected DB_CONNECTION is asserted; focused lifecycle cases
retain intentional isolated SQLite aliases. Use only disposable test databases.
The foundation tests cover disabled and explicitly registered schema installation, package-directory provisioning and lifecycle, immutable context values, typed missing-context failures, lazy adapter validation, scoped execution, transaction balance, participant restoration, HTTP admission, and recovery authorization, audit, and queue boundaries. See UPGRADING.md, SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md.
Supported PHP usage
The source @api declarations identify supported workflows, extension contracts, and value types. Public members marked @internal and untagged implementation types remain package-owned. Concrete Actions retain their existing constructors, qualifiers, and execute() signatures.
A package model returned or accepted by a public workflow is an identity/result handle. Use its declared type and getKey(), getKeyName(), getMorphClass(), getRouteKey(), getRouteKeyName(), is(), isNot(), and relationLoaded(). Read only explicitly declared in-memory @nvl-consumer-read fields; ordinary model PHPDocs and fillable attributes do not grant consumer reads. Obtain display projections through public reads. Persistence, additional model queries, relation access/loading, and generic model serialization are outside this contract. Host-model queries remain available, while traversal or aggregates of package capability relations require the package public reader or authorized adapter.
Testing your app
Inject ProvisionTenantContract and ChangeTenantStatusContract for platform
workflows. Continue to inject neutral Nvl\Support\Tenancy\Contracts for tenant
context, directory, membership, boundaries, runners, and queue context. The
provider preserves effective host adapters; runtime compatibility aliases remain
available. A fake platform workflow is not an admission or authorization proof.
use Nvl\Support\Tenancy\ValueObjects\PlatformOperation; use Nvl\Support\Tenancy\ValueObjects\TenantId; use Nvl\Tenancy\Contracts\ChangeTenantStatusContract; use Nvl\Support\Tenancy\Enums\TenantStatus; final readonly class SuspendTenant { public function __construct(private ChangeTenantStatusContract $tenants) {} public function run(TenantId $tenant, PlatformOperation $operation): void { $this->tenants->execute($tenant, TenantStatus::Suspended, $operation); } } $tenant = new TenantId('00000000-0000-4000-8000-000000000001'); $operation = new PlatformOperation('suspend', 'user', 'operator-id'); $tenants = Mockery::mock(ChangeTenantStatusContract::class); $tenants->shouldReceive('execute')->once() ->with($tenant, TenantStatus::Suspended, $operation)->andReturnNull(); $this->app->instance(ChangeTenantStatusContract::class, $tenants); $this->app->make(SuspendTenant::class)->run($tenant, $operation);
Use direct TenantId/descriptor/operation values for orchestration fixtures and
an unsaved new Nvl\Tenancy\Models\Tenant only when a model handle is needed.
Persist real tenants through the platform workflow on its configured schema.
Explicitly enter tenant context in integration tests; no fixture should activate
Tenancy or admit host principals implicitly. Queue::fake() can verify host
dispatch, while admission, context restoration, and queue-envelope isolation need
real boundary tests across separate request/job lifecycles.
In Laravel application tests, register a native Mockery interface mock or a small
implementation with $this->app->instance(Contract::class, $substitute) before
resolving your application service. A host binding installed before package
registration is retained; later contract replacements affect subsequent
resolutions. Rebuild previously resolved host services after replacing their
dependencies. Concrete implementations remain callable with their original
constructors through major 5. Mocks exercise your application orchestration;
package authorization, persistence, and external effects need real integration
tests.
Both platform workflows gain focused contracts. Neutral Core contracts, effective host adapter identity, compatibility aliases, security, and inactive-by-default behavior are preserved.
For static consumer checks, include the shipped
consumer-audit.neon from
vendor/nvl/core/support/consumer-audit.neon in your host PHPStan configuration
and configure explicit nvlConsumer.testPaths for factory-backed tests. The
extension checks supported APIs and model/query boundaries; it does not prove
authorization or arbitrary dynamic SQL.
Queued tenant work
Implement Nvl\Tenancy\Contracts\TenantQueuedJob on each tenant command and capture
its immutable TenantJobEnvelope when constructing it inside TenantRunner::run():
final class RebuildTenantIndex implements ShouldQueue, TenantQueuedJob { use Queueable; public function __construct( public readonly TenantJobEnvelope $envelope, public readonly string $recordId, ) {} public function tenantJobEnvelope(): TenantJobEnvelope { return $this->envelope; } } $job = new RebuildTenantIndex(TenantJobEnvelope::capture(app(TenantContext::class)), $recordId); Bus::dispatch($job);
Use the normal imports for Laravel's ShouldQueue, Queueable, and Bus and the
Tenancy contracts/value objects shown above. Package commands carry scalar IDs;
inside handle() use the owning package boundary to load and verify the work item.
The persisted owner must still match the envelope on every attempt.
Capture must precede PendingDispatch, unique-lock acquisition, afterResponse,
and sync afterCommit scheduling. Enabled tenancy rejects uncaptured commands even
if a tenant happens to be active when Laravel finally serializes them. The native
host bus dispatcher and its response-deferral setting are preserved. A job's
uniqueId() and WithoutOverlapping key must include its captured tenant ID,
not whichever ambient scope later publishes or consumes it. Tenant-aware resource
keys can be captured through TenantBoundary::key() at construction.
The versioned scalar data.nvl_tenancy envelope is validated before both native
CallQueuedHandler::call() and failed() deserialize user commands. Unknown
versions, invalid modes, missing metadata, inactive tenants, incompatible loaded
providers, and worker feature-mode mismatches fail closed. Failure before handle()
and retry use the same boundary. Maintenance/adoption grants cannot publish work.
Disabled legacy object dispatch, including pre-installation payloads without metadata, remains available only against unadopted resources;
an enabled worker never accepts a Disabled envelope.
The provider uses bindIf(CallQueuedHandler::class, TenantCallQueuedHandler::class).
An existing host implementation is retained. It must implement Core's
TenantQueueHandler contract: validate() admits the scoped envelope and inert
command/model graph without restoring user objects, while call() and failed()
revalidate before restoration. Extending TenantCallQueuedHandler and preserving
all three methods is the supplied adapter. Core invokes validate() at
JobProcessing, before native execution and terminal failure handling. Admission
errors, including carried-envelope and model-owner mismatches, enter raw
quarantine; ordinary admitted handle() failures retain native failure handling.
Core inspects
the actual payload handler and permits only its guarded call entry point when
delegating to enabled Tenancy. A retained incompatible handler quarantines
previously captured NVL work before command restoration; ordinary host jobs
without NVL metadata keep their handler. Doctor requires compatible handlers and
batch repositories when Tenancy is enabled or resource storage is adopted;
disabled legacy storage preserves the host implementations. Publication also
rejects incompatible handlers. Retained services
resolve the current scoped context on every entry. String jobs and other custom
handler routes require a separate explicit adapter. Queue payload hooks must merge
nested data; overwriting the envelope causes worker rejection.
Specific trusted global identity commands can register their exact class through
TenantGlobalJobRegistry::register(). They run in Unresolved context and may carry
only scalar properties and arrays. Framework wrappers and subclasses cannot be
registered as global identity jobs. Both plaintext and native encrypted commands
receive root-class and inert data checks before native deserialization. Neither a
claimed commandName nor PHP's incomplete-class inspection metadata grants access.
Supported host SerializesModels properties require registered canonical resource
models, scalar identifiers, canonical connections, no serialized relationships, and
no custom collection classes. Before native restoration, inert ModelIdentifier
data is checked against persisted ownership. Missing, moved, foreign, unregistered,
or unsupported identifiers fail closed before the command's __unserialize();
this deliberately does not apply native delete-when-missing behavior to a denied
ownership check. Use scalar IDs and explicit package readers for richer graphs.
The reserved __PHP_Incomplete_Class_Name inspection marker is rejected in command
bytes, including scalar content. Serialized object graphs have a bounded depth.
Chains must carry the same captured tenant on every command; their native nested
serialized commands are checked before the initial command is deserialized.
Fixed native wrapper adapters support SendQueuedMailable when its mailable
implements TenantQueuedJob, SendQueuedNotifications when its notification does,
and CallQueuedListener when its event arguments contain explicitly captured
carriers with matching envelopes. Capture in the mailable/notification/event
constructor before native dispatch. Prefer scalar or on-demand recipients; model
recipients follow the same registered-identifier limits. Uncaptured wrappers,
custom subclasses, mismatched event carriers, and legacy serialized-string listener
arguments fail closed. Wrapper classes are never global identity registrations.
Native database batches
Capture a pending batch while its producer tenant is active, then dispatch it in that same tenant scope:
app(TenantQueueContext::class) ->captureBatch(Bus::batch([$firstCapturedJob, $secondCapturedJob])->then($callback)) ->dispatch();
Every command and callback belongs to one captured tenant. Mixed jobs, later-added
foreign jobs, missing capture, and changed persisted options are rejected. Native
batch afterResponse publication in a later unrelated or Unresolved scope is
unsupported and fails closed; individual captured job afterResponse is supported.
TenantDatabaseBatchRepository retains Laravel's database batch algorithms and
validates inert persisted options before native callback deserialization. The
provider adapts the exact native database repository with its actual factory,
connection, and table. Host subclasses/custom repositories are preserved and must
explicitly inherit the guarded adapter; Doctor and batch capture reject incompatible
implementations. PostgreSQL's native base64 representation is preserved. Batch reads
must run in the captured tenant context. Native signed callback payloads receive
recursive inert model checks while native signature verification remains in place.
Arbitrary application __unserialize() implementations are trusted application code;
this boundary does not sandbox code hidden inside custom serialized strings.
Shared consumer diagnostics
Run php artisan nvl:doctor --strict --format=json to combine the read-only checks from loaded NVL package providers. Errors fail the gate, and strict mode also fails warnings. This package's existing Doctor command remains available and uses the same package-owned inspection service.
Neutral tenant boundaries
Core owns Nvl\Support\Tenancy contracts, immutable identifiers and snapshots, queue envelopes, resource definitions, and the resource metadata registry. Packages consume TenantBoundary, TenantContext, TenantRunner, TenantQueueContext, TenantInstallationState, and TenantOwnershipConfiguration contracts from that namespace. Registering a neutral package registers Core; it does not select the enforcing Tenancy provider.
Without nvl/tenancy, the disabled boundary preserves validated legacy queries and identity keys and supplies no ownership attributes. Tenant context is disabled, and requiring a tenant or privileged platform execution fails. The actual resource connection is checked for persisted adoption before access. Setting tenancy.enabled=true without the enforcing provider fails, and adopted storage cannot be reopened through disabled defaults. Queue admission runs before native command deserialization and rejects captured tenant work or adopted storage without the runtime.
nvl/tenancy is suggested by neutral packages and required for their tenancy test profiles. Select its provider through Laravel discovery or register it explicitly to activate the runtime implementations. Merely having its classes on disk does not register adoption adapters. Billing continues to require the runtime.
Next major: isolated schema identities
Use nvl-tenancy.tables.<logical-key> for every table and nvl-tenancy.connection for its database connection. Null connection inherits nvl-core.connection, then Laravel's default. Tables are resolved at runtime by the package table definition helper.
| Logical key | New default | Previous name |
|---|---|---|
tenants |
nvl_tenancy_tenants |
nvl_tenancy_tenants |
adoption_runs |
nvl_tenancy_adoption_runs |
nvl_tenancy_adoption_runs |
installation_state |
nvl_tenancy_installation_state |
nvl_tenancy_installation_state |
operations |
nvl_tenancy_operations |
nvl_tenancy_operations |
adoption_mappings |
nvl_tenancy_adoption_mappings |
nvl_tenancy_adoption_mappings |
Migration filenames contain nvl_tenancy_. Existing installations must complete the upgrade in UPGRADING.md before running new migrations. A pending creator rejects an existing target before that owned migration runs; use nvl:schema:preflight for an explicit whole-batch check; legacy storage with old history needs an ownership decision.
Canonical configuration ownership
Use nvl-tenancy settings in config/nvl-tenancy.php and canonical package environment names. Old generic roots are foreign unless an upgrading NVL host explicitly selects them in Core's default-off compatibility. Canonical false/null/empty values win; no old roots are populated or written back. Keep logical package/resource IDs unchanged. Review Core's rename inventory and cache/worker cutover.
Testing your app
Inject the supported contract rather than constructing its concrete Action or querying package tables. Replace Nvl\Support\Tenancy\Contracts\TenantDirectory in Laravel's native container for a host-workflow test:
use Nvl\Support\Tenancy\Contracts\TenantDirectory; $double = Mockery::mock(TenantDirectory::class); $this->app->instance(TenantDirectory::class, $double); // Configure the exact find arguments and documented return value for your host case.
The package's conditional native binding preserves host substitutions. Production uses the real contract; test doubles do not prove its storage/authorization behavior.
A detached fixture for a returned identity/data handle is:
use Nvl\Tenancy\Models\Tenant; $fixture = Tenant::factory()->withoutParents()->make();
Ordinary make() may persist declared package parents. withoutParents()->make() disables parent expansion/admission for detached fixtures; use explicit persisted parents/owners and matching effective connections for a real create(). Factories do not authorize workflows, call Stripe, create backing Media objects or publish Template artifacts. Enabled tenancy requires explicit admitted persisted tenants/parents. Your host test installation supplies Faker; no test runner is a runtime package dependency.
Use Laravel Event::fake(), Queue::fake(), Mail::fake() or Storage::fake() only for the effects the host test intends to isolate. Use real commits/listeners for timing proof. Add the optional Core consumer boundary rules to host PHPStan:
includes: - vendor/nvl/core/support/consumer-audit.neon parameters: nvlConsumer: testPaths: [tests] tableNames: [] exceptions: []
Rules read installed public metadata without suite boot. They flag internal symbols, package model queries/writes, capability relations and owned tables; they cannot prove dynamic code or runtime authorization. Exact exceptions require file, identifier, symbol, and a documented reason. The published 5.x family is verified through the local Dagger release gate on PHP 8.4/Laravel 13, including owning suites, MySQL/PostgreSQL persistence contracts and sealed Tenancy consumers. Fresh public Composer installation, discovery and configuration/route caching are verified. PHP 8.5, Laravel 12, MariaDB and the full independent archive matrix require separate evidence. See the verification and release policy.
Shipped factory states
These runtime builders keep Laravel's native Factory API. The listed methods name explicit supported parent/owner/lifecycle states; follow each factory's native admission requirements. Detached examples above do not assert persistence validity.
| Factory | Explicit states |
|---|---|
TenantFactory |
Native Factory states only |
Error codes and events
All recognized package failures implement Nvl\Support\Contracts\PackageException; only RespondableException opts into safe response metadata. Keep native PHP programmer errors and Laravel/SDK exceptions distinct. The optional PackageExceptionRenderer is registered by the host in withExceptions; it leaves unrelated, marker-only and non-JSON handling to the host. Its JSON envelope is {message:string, code:string, context:object}. Request locale is host-owned; diagnostics/previous exceptions are not public copy. Event schemas and source connections are documented in events.
The table lists enum discriminators, including any successful codes retained for compatibility. A code is not itself an HTTP status; the throwing exception's suggestedStatus() is authoritative, especially legacy/custom constructors. Empty context renders as {}; only documented JSON-safe context is presented.
| Code | Suggested status | Public context | Translation key |
|---|---|---|---|
operation_failed |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-core::responsecode.operation_failed |
tenant_context_missing |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-core::responsecode.tenant_context_missing |
tenant_not_found |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-core::responsecode.tenant_not_found |
tenant_inactive |
409 | Declared safe scalar/array map; otherwise {} |
nvl-core::responsecode.tenant_inactive |
tenant_boundary_violation |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-core::responsecode.tenant_boundary_violation |
tenant_configuration_invalid |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-core::responsecode.tenant_configuration_invalid |
tenant_schema_not_ready |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-core::responsecode.tenant_schema_not_ready |
License
Released under the MIT License.