PHPackages                             arielespinoza07/tenancy-core - 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. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. arielespinoza07/tenancy-core

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

arielespinoza07/tenancy-core
============================

Framework-agnostic tenancy core for PHP applications, providing tenant resolution, tenant context, access guards, and authorization contracts.

v2.0.0(1mo ago)051MITPHPPHP ^8.5CI passing

Since Jun 10Pushed 1mo agoCompare

[ Source](https://github.com/ArielEspinoza07/tenancy-core)[ Packagist](https://packagist.org/packages/arielespinoza07/tenancy-core)[ RSS](/packages/arielespinoza07-tenancy-core/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (4)Dependencies (7)Versions (5)Used By (1)

Tenancy Core
============

[](#tenancy-core)

[![CI](https://github.com/arielespinoza07/tenancy-core/actions/workflows/ci.yml/badge.svg)](https://github.com/arielespinoza07/tenancy-core/actions/workflows/ci.yml)[![Latest Version](https://camo.githubusercontent.com/2e8cd8347dc3d298aeb4d44f42c265e010cd3576bdc45d6957611b55815b7eef/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f617269656c657370696e6f7a6130372f74656e616e63792d636f72652e737667)](https://packagist.org/packages/arielespinoza07/tenancy-core)[![Total Downloads](https://camo.githubusercontent.com/b2f121f24e70f0531bee2719ba015b0cc795b1453238d74ba9c28671eb20eb2c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f617269656c657370696e6f7a6130372f74656e616e63792d636f72652e737667)](https://packagist.org/packages/arielespinoza07/tenancy-core)[![PHP Version](https://camo.githubusercontent.com/412ae69f9a2611f94b132f61184b244f16fc2f531b513079d3763a5c415bec71/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f617269656c657370696e6f7a6130372f74656e616e63792d636f72652e737667)](https://packagist.org/packages/arielespinoza07/tenancy-core)[![License](https://camo.githubusercontent.com/f7eb6d87edff08d71bd311a3b7d313bf53814e05e20da4ad86bf61b7af34c529/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f617269656c657370696e6f7a6130372f74656e616e63792d636f72652e737667)](LICENSE)

Framework-agnostic tenancy core for PHP applications, providing tenant resolution, tenant context, access guards, and authorization contracts.

---

Requirements
------------

[](#requirements)

- PHP 8.5+

---

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

[](#installation)

```
composer require arielespinoza07/tenancy-core
```

---

Concepts
--------

[](#concepts)

The package is built around four responsibilities:

ResponsibilityClassResolve which tenant owns a request`ChainTenantResolver` + strategiesHold the resolved tenant for the request`CurrentTenant`Check whether a user can access a tenant`TenantAccessGuard`Check whether a user has a permission within a tenant`TenantPermissionChecker`All heavy lifting (database queries, session reads, etc.) is behind interfaces that **you** implement for your framework and data layer.

---

Implementing the interfaces
---------------------------

[](#implementing-the-interfaces)

### TenantLookupInterface

[](#tenantlookupinterface)

Used by most resolution strategies to fetch a tenant by slug, domain, or ID.

```
use Tenancy\Contracts\Repositories\TenantLookupInterface;
use Tenancy\Contracts\Records\TenantRecordInterface;

final class EloquentTenantLookup implements TenantLookupInterface
{
    public function findBySlug(string $slug): ?TenantRecordInterface
    {
        return Tenant::whereSlug($slug)->first()?->toTenantRecord();
    }

    public function findByDomain(string $domain): ?TenantRecordInterface
    {
        return Tenant::whereDomain($domain)->first()?->toTenantRecord();
    }

    public function findById(int|string $id): ?TenantRecordInterface
    {
        return Tenant::find($id)?->toTenantRecord();
    }
}
```

### TenantRecordInterface

[](#tenantrecordinterface)

The package ships a ready-to-use concrete implementation: `Tenancy\Records\TenantRecord`. You can instantiate it directly from whatever data source your application uses:

```
use Tenancy\Records\TenantRecord;
use Tenancy\Enums\TenantStatus;

new TenantRecord(
    id: $row->id,
    name: $row->name,
    slug: $row->slug,
    domain: $row->domain,
    metadata: $row->metadata,
    tenantStatus: TenantStatus::from($row->status),
);
```

`TenantRecord` also ships `toArray()` and `fromArray()` for cache serialization:

```
// Serialize to cache
$data = $record->toArray();

// Rehydrate from cache
$record = TenantRecord::fromArray($data);
```

`TenantApiKeyRecord` follows the same pattern, and its `fromArray()` rehydrates the nested tenant record automatically:

```
$data   = $apiKeyRecord->toArray();
$record = TenantApiKeyRecord::fromArray($data);
```

If the built-in record does not fit your data model, implement `TenantRecordInterface` directly. Note that `toArray()` is part of the interface and must be implemented:

```
use Tenancy\Contracts\Records\TenantRecordInterface;

final readonly class MyTenantRecord implements TenantRecordInterface
{
    public function __construct(
        public int|string $id,
        public string $name,
        public string $slug,
        public string $status,
        public string $billingStatus,
        public ?string $domain,
        public array $metadata,
    ) {}

    public function isActive(): bool
    {
        return $this->status === 'active'
            && in_array($this->billingStatus, ['paid', 'trial']);
    }

    public function isSuspended(): bool
    {
        return $this->status === 'suspended'
            || $this->billingStatus === 'overdue';
    }

    public function isDeleted(): bool
    {
        return $this->status === 'deleted';
    }

    public function isPending(): bool
    {
        return $this->status === 'pending';
    }

    public function toArray(): array
    {
        return [
            'id'            => $this->id,
            'name'          => $this->name,
            'slug'          => $this->slug,
            'domain'        => $this->domain,
            'metadata'      => $this->metadata,
            'status'        => $this->status,
            'billingStatus' => $this->billingStatus,
        ];
    }
}
```

### MembershipRepositoryInterface

[](#membershiprepositoryinterface)

Used by `TenantAccessGuard` to check whether a user belongs to a tenant:

```
use Tenancy\Contracts\Repositories\MembershipRepositoryInterface;

final class EloquentMembershipRepository implements MembershipRepositoryInterface
{
    public function existsActiveMembership(int|string $userId, int|string $tenantId): bool
    {
        return Membership::where('user_id', $userId)
            ->where('tenant_id', $tenantId)
            ->where('status', 'active')
            ->exists();
    }
}
```

### TenantPermissionRepositoryInterface

[](#tenantpermissionrepositoryinterface)

Used by `TenantPermissionChecker`:

```
use Tenancy\Contracts\Repositories\TenantPermissionRepositoryInterface;

final class EloquentTenantPermissionRepository implements TenantPermissionRepositoryInterface
{
    public function userHasPermission(int|string $tenantId, int|string $userId, string $permission): bool
    {
        return Role::forTenant($tenantId)
            ->forUser($userId)
            ->whereHas('permissions', fn ($q) => $q->where('name', $permission))
            ->exists();
    }
}
```

### TenantApiKeyLookupInterface

[](#tenantapikeylookupinterface)

Used by `ApiKeyTenantResolutionStrategy`. It receives the plain-text key from the request and must return a `TenantApiKeyRecordInterface` — or `null` if the key does not exist.

API keys should be stored **hashed** in your database, so the implementation hashes the incoming plain-text key before querying. The package's concrete `TenantApiKeyRecord` and `TenantRecord` can be returned directly:

```
use DateTimeImmutable;
use Tenancy\Contracts\Records\TenantApiKeyRecordInterface;
use Tenancy\Contracts\Repositories\TenantApiKeyLookupInterface;
use Tenancy\Enums\TenantStatus;
use Tenancy\Records\TenantApiKeyRecord;
use Tenancy\Records\TenantRecord;

final class EloquentTenantApiKeyLookup implements TenantApiKeyLookupInterface
{
    public function findByPlainTextKey(string $plainTextKey): ?TenantApiKeyRecordInterface
    {
        $row = ApiKey::with('tenant')
            ->where('key_hash', hash('sha256', $plainTextKey))
            ->first();

        if ($row === null) {
            return null;
        }

        return new TenantApiKeyRecord(
            tenant: new TenantRecord(
                id: $row->tenant->id,
                name: $row->tenant->name,
                slug: $row->tenant->slug,
                domain: $row->tenant->domain,
                metadata: $row->tenant->metadata ?? [],
                tenantStatus: TenantStatus::from($row->tenant->status),
            ),
            revoked: (bool) $row->revoked,
            expiresAt: $row->expires_at
                ? new DateTimeImmutable($row->expires_at)
                : null,
        );
    }
}
```

`TenantApiKeyRecord::isActive()` then handles expiry and revocation checks internally — the strategy throws `TenantNotFoundException` if it returns `false`.

---

Wiring up the resolver
----------------------

[](#wiring-up-the-resolver)

Build a `TenantResolverRegistry`, add strategies in priority order (higher number = tried first), then wrap it in `ChainTenantResolver`:

```
use Tenancy\Resolution\ChainTenantResolver;
use Tenancy\Resolution\TenantResolverRegistry;
use Tenancy\Resolution\Strategies\SubdomainTenantResolutionStrategy;
use Tenancy\Resolution\Strategies\ApiKeyTenantResolutionStrategy;
use Tenancy\Support\HostNormalizer;

$normalizer = new HostNormalizer();
$lookup     = new EloquentTenantLookup();

$registry = new TenantResolverRegistry();
$registry->add(new ApiKeyTenantResolutionStrategy($apiKeyLookup), priority: 20);
$registry->add(new SubdomainTenantResolutionStrategy($lookup, $normalizer, 'example.com'), priority: 10);

$resolver = new ChainTenantResolver($registry);
```

---

Resolving a request
-------------------

[](#resolving-a-request)

Build a `TenantResolutionInput` from the incoming request and call `resolve()`:

```
use Tenancy\Resolution\TenantResolutionInput;

$input = TenantResolutionInput::fromArray([
    'host'            => $request->getHost(),
    'path'            => $request->getPathInfo(),
    'headers'         => $request->headers->all(),
    'sessionTenantId' => $session->get('tenant_id'),
    'userId'          => $auth->id(),
]);

$context = $resolver->resolve($input);  // throws on failure
```

Then store it in `CurrentTenant` for the duration of the request:

```
use Tenancy\Context\CurrentTenant;

$currentTenant = new CurrentTenant();
$currentTenant->set($context);

// Later in the request lifecycle:
$context   = $currentTenant->get();          // throws TenantContextMissingException if not set
$tenantId  = $currentTenant->get()->record->id;
$isSystem  = $currentTenant->get()->isSystem();
```

#### Lifecycle in long-running servers

[](#lifecycle-in-long-running-servers)

In **PHP-FPM** every request runs in a fresh process, so `CurrentTenant` is naturally reset between requests.

In **long-running servers** (Laravel Octane, Swoole, RoadRunner) the same process handles multiple requests. If `CurrentTenant` is registered as a singleton it will carry the previous request's tenant into the next one.

Always call `clear()` at the end of each request — typically in a terminating middleware:

```
// Framework-agnostic terminating middleware example
public function terminate(): void
{
    $this->currentTenant->clear();
}
```

If your framework supports request-scoped bindings, binding `CurrentTenant` per-request is the cleanest solution and makes the manual `clear()` unnecessary.

#### Running code under a specific tenant

[](#running-code-under-a-specific-tenant)

Use `scoped()` when you need to temporarily switch context — background jobs, data migrations, or console commands that iterate over tenants:

```
foreach ($tenants as $tenantRecord) {
    $context = new TenantContext(record: $tenantRecord, source: TenantResolutionSource::Console);

    $currentTenant->scoped($context, function () use ($currentTenant) {
        // runs under $tenantRecord; previous context is restored on exit, even on exceptions
        $this->processReports($currentTenant->get());
    });
}
```

#### Running code without any tenant context

[](#running-code-without-any-tenant-context)

Use `withoutContext()` to temporarily drop the tenant for global or system-level operations:

```
$currentTenant->withoutTenant(function () {
    // no tenant is set here
    $this->syncGlobalConfig();
});
// previous context is restored on exit
```

---

Checking access and permissions
-------------------------------

[](#checking-access-and-permissions)

```
use Tenancy\Access\TenantAccessGuard;
use Tenancy\Authorization\TenantPermissionChecker;

$guard = new TenantAccessGuard(new EloquentMembershipRepository());
$guard->ensureAccess($userId, $context);  // throws TenantAccessDeniedException

$checker = new TenantPermissionChecker(new EloquentTenantPermissionRepository());
$checker->ensureCan($userId, $context, 'posts.publish');  // throws TenantPermissionDeniedException
```

---

Available resolution strategies
-------------------------------

[](#available-resolution-strategies)

StrategyReads fromDefault header / key`SubdomainTenantResolutionStrategy`Subdomain of a configured base domain—`CustomDomainTenantResolutionStrategy`Full custom domain mapped to a tenant—`PathTenantResolutionStrategy`First URL path segment (or after a prefix)—`HeaderTenantResolutionStrategy`Request header (tenant ID)`X-Tenant-ID``HeaderTenantSlugResolutionStrategy`Request header (tenant slug)`X-Tenant-Slug``SessionTenantResolutionStrategy`Session value—`ApiKeyTenantResolutionStrategy`Bearer token, `X-API-Key` header, or explicit field`Authorization` / `X-API-Key``ChainTenantResolver` runs all registered strategies, collecting results. If all results agree on the same tenant it returns the first; if they conflict it throws `TenantResolutionConflictException`.

---

Exception hierarchy
-------------------

[](#exception-hierarchy)

```
TenantException
├── TenantAuthorizationException
│   ├── TenantAccessDeniedException
│   └── TenantPermissionDeniedException
├── TenantContextMissingException
└── TenantResolutionException
    ├── TenantNotFoundException
    ├── TenantNotResolvedException
    ├── TenantResolutionConflictException
    └── TenantSuspendedException

```

---

Contributing
------------

[](#contributing)

See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, code conventions, and PR guidelines.

---

License
-------

[](#license)

[MIT License](LICENSE)

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance92

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity54

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 ~2 days

Total

4

Last Release

39d ago

Major Versions

v1.1.0 → v2.0.02026-06-16

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/9816498?v=4)[Ariel Espinoza](/maintainers/ArielEspinoza07)[@ArielEspinoza07](https://github.com/ArielEspinoza07)

---

Top Contributors

[![ArielEspinoza07](https://avatars.githubusercontent.com/u/9816498?v=4)](https://github.com/ArielEspinoza07 "ArielEspinoza07 (24 commits)")

---

Tags

access-controlauthorizationframework-agnosticmiddlewaremulti-tenantphpphp85saastenancytenant-resolutionphpsaasframework agnosticmulti-tenanttenancy

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/arielespinoza07-tenancy-core/health.svg)

```
[![Health](https://phpackages.com/badges/arielespinoza07-tenancy-core/health.svg)](https://phpackages.com/packages/arielespinoza07-tenancy-core)
```

###  Alternatives

[hyn/multi-tenant

Run multiple websites using the same laravel installation while keeping tenant specific data separated for fully independant multi-domain setups.

2.6k1.2M9](/packages/hyn-multi-tenant)[andrewdwallo/filament-companies

A comprehensive Laravel authentication and authorization system designed for Filament, focusing on multi-tenant company management.

35156.4k2](/packages/andrewdwallo-filament-companies)[tenancy/framework

Creating multi tenant saas from your Laravel app with ease

13246.2k42](/packages/tenancy-framework)

PHPackages © 2026

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