{"id":50840550,"url":"https://github.com/arielespinoza07/tenancy-core","last_synced_at":"2026-06-16T08:00:52.487Z","repository":{"id":364325094,"uuid":"1264506345","full_name":"ArielEspinoza07/tenancy-core","owner":"ArielEspinoza07","description":"Framework-agnostic tenancy core for PHP — tenant resolution, context, access guards, and authorization contracts.","archived":false,"fork":false,"pushed_at":"2026-06-12T14:41:26.000Z","size":47,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-14T06:39:02.380Z","etag":null,"topics":["access-control","authorization","framework-agnostic","middleware","multi-tenant","php","php85","saas","tenancy","tenant-resolution"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ArielEspinoza07.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-10T00:16:32.000Z","updated_at":"2026-06-12T14:40:25.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ArielEspinoza07/tenancy-core","commit_stats":null,"previous_names":["arielespinoza07/tenancy-core"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/ArielEspinoza07/tenancy-core","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ArielEspinoza07%2Ftenancy-core","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ArielEspinoza07%2Ftenancy-core/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ArielEspinoza07%2Ftenancy-core/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ArielEspinoza07%2Ftenancy-core/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ArielEspinoza07","download_url":"https://codeload.github.com/ArielEspinoza07/tenancy-core/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ArielEspinoza07%2Ftenancy-core/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34351451,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-15T02:00:07.085Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["access-control","authorization","framework-agnostic","middleware","multi-tenant","php","php85","saas","tenancy","tenant-resolution"],"created_at":"2026-06-14T06:32:05.752Z","updated_at":"2026-06-16T08:00:52.482Z","avatar_url":"https://github.com/ArielEspinoza07.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Tenancy Core\n\n[![CI](https://github.com/arielespinoza07/tenancy-core/actions/workflows/ci.yml/badge.svg)](https://github.com/arielespinoza07/tenancy-core/actions/workflows/ci.yml)\n[![Latest Version](https://img.shields.io/packagist/v/arielespinoza07/tenancy-core.svg)](https://packagist.org/packages/arielespinoza07/tenancy-core)\n[![Total Downloads](https://img.shields.io/packagist/dt/arielespinoza07/tenancy-core.svg)](https://packagist.org/packages/arielespinoza07/tenancy-core)\n[![PHP Version](https://img.shields.io/packagist/php-v/arielespinoza07/tenancy-core.svg)](https://packagist.org/packages/arielespinoza07/tenancy-core)\n[![License](https://img.shields.io/packagist/l/arielespinoza07/tenancy-core.svg)](LICENSE)\n\nFramework-agnostic tenancy core for PHP applications, providing tenant resolution, tenant context, access guards, and authorization contracts.\n\n---\n\n## Requirements\n\n- PHP 8.5+\n\n---\n\n## Installation\n\n```bash\ncomposer require arielespinoza07/tenancy-core\n```\n\n---\n\n## Concepts\n\nThe package is built around four responsibilities:\n\n| Responsibility | Class |\n|---|---|\n| Resolve which tenant owns a request | `ChainTenantResolver` + strategies |\n| Hold the resolved tenant for the request | `CurrentTenant` |\n| Check whether a user can access a tenant | `TenantAccessGuard` |\n| Check whether a user has a permission within a tenant | `TenantPermissionChecker` |\n\nAll heavy lifting (database queries, session reads, etc.) is behind interfaces that **you** implement for your framework and data layer.\n\n---\n\n## Implementing the interfaces\n\n### TenantLookupInterface\n\nUsed by most resolution strategies to fetch a tenant by slug, domain, or ID.\n\n```php\nuse Tenancy\\Contracts\\Repositories\\TenantLookupInterface;\nuse Tenancy\\Contracts\\Records\\TenantRecordInterface;\n\nfinal class EloquentTenantLookup implements TenantLookupInterface\n{\n    public function findBySlug(string $slug): ?TenantRecordInterface\n    {\n        return Tenant::whereSlug($slug)-\u003efirst()?-\u003etoTenantRecord();\n    }\n\n    public function findByDomain(string $domain): ?TenantRecordInterface\n    {\n        return Tenant::whereDomain($domain)-\u003efirst()?-\u003etoTenantRecord();\n    }\n\n    public function findById(int|string $id): ?TenantRecordInterface\n    {\n        return Tenant::find($id)?-\u003etoTenantRecord();\n    }\n}\n```\n\n### TenantRecordInterface\n\nThe package ships a ready-to-use concrete implementation: `Tenancy\\Records\\TenantRecord`. You can instantiate it directly from whatever data source your application uses:\n\n```php\nuse Tenancy\\Records\\TenantRecord;\nuse Tenancy\\Enums\\TenantStatus;\n\nnew TenantRecord(\n    id: $row-\u003eid,\n    name: $row-\u003ename,\n    slug: $row-\u003eslug,\n    domain: $row-\u003edomain,\n    metadata: $row-\u003emetadata,\n    tenantStatus: TenantStatus::from($row-\u003estatus),\n);\n```\n\nIf the built-in record does not fit your data model, implement `TenantRecordInterface` directly:\n\n```php\nuse Tenancy\\Contracts\\Records\\TenantRecordInterface;\n\nfinal readonly class MyTenantRecord implements TenantRecordInterface\n{\n    public function __construct(\n        public int|string $id,\n        public string $name,\n        public string $slug,\n        public string $status,\n        public string $billingStatus,\n        public ?string $domain,\n        public array $metadata,\n    ) {}\n\n    public function isActive(): bool\n    {\n        return $this-\u003estatus === 'active'\n            \u0026\u0026 in_array($this-\u003ebillingStatus, ['paid', 'trial']);\n    }\n\n    public function isSuspended(): bool\n    {\n        return $this-\u003estatus === 'suspended'\n            || $this-\u003ebillingStatus === 'overdue';\n    }\n\n    public function isDeleted(): bool\n    {\n        return $this-\u003estatus === 'deleted';\n    }\n\n    public function isPending(): bool\n    {\n        return $this-\u003estatus === 'pending';\n    }\n}\n```\n\n### MembershipRepositoryInterface\n\nUsed by `TenantAccessGuard` to check whether a user belongs to a tenant:\n\n```php\nuse Tenancy\\Contracts\\Repositories\\MembershipRepositoryInterface;\n\nfinal class EloquentMembershipRepository implements MembershipRepositoryInterface\n{\n    public function existsActiveMembership(int|string $userId, int|string $tenantId): bool\n    {\n        return Membership::where('user_id', $userId)\n            -\u003ewhere('tenant_id', $tenantId)\n            -\u003ewhere('status', 'active')\n            -\u003eexists();\n    }\n}\n```\n\n### TenantPermissionRepositoryInterface\n\nUsed by `TenantPermissionChecker`:\n\n```php\nuse Tenancy\\Contracts\\Repositories\\TenantPermissionRepositoryInterface;\n\nfinal class EloquentTenantPermissionRepository implements TenantPermissionRepositoryInterface\n{\n    public function userHasPermission(int|string $tenantId, int|string $userId, string $permission): bool\n    {\n        return Role::forTenant($tenantId)\n            -\u003eforUser($userId)\n            -\u003ewhereHas('permissions', fn ($q) =\u003e $q-\u003ewhere('name', $permission))\n            -\u003eexists();\n    }\n}\n```\n\n### TenantApiKeyLookupInterface\n\nUsed by `ApiKeyTenantResolutionStrategy`. It receives the plain-text key from the request and must return a `TenantApiKeyRecordInterface` — or `null` if the key does not exist.\n\nAPI 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:\n\n```php\nuse DateTimeImmutable;\nuse Tenancy\\Contracts\\Records\\TenantApiKeyRecordInterface;\nuse Tenancy\\Contracts\\Repositories\\TenantApiKeyLookupInterface;\nuse Tenancy\\Enums\\TenantStatus;\nuse Tenancy\\Records\\TenantApiKeyRecord;\nuse Tenancy\\Records\\TenantRecord;\n\nfinal class EloquentTenantApiKeyLookup implements TenantApiKeyLookupInterface\n{\n    public function findByPlainTextKey(string $plainTextKey): ?TenantApiKeyRecordInterface\n    {\n        $row = ApiKey::with('tenant')\n            -\u003ewhere('key_hash', hash('sha256', $plainTextKey))\n            -\u003efirst();\n\n        if ($row === null) {\n            return null;\n        }\n\n        return new TenantApiKeyRecord(\n            tenant: new TenantRecord(\n                id: $row-\u003etenant-\u003eid,\n                name: $row-\u003etenant-\u003ename,\n                slug: $row-\u003etenant-\u003eslug,\n                domain: $row-\u003etenant-\u003edomain,\n                metadata: $row-\u003etenant-\u003emetadata ?? [],\n                tenantStatus: TenantStatus::from($row-\u003etenant-\u003estatus),\n            ),\n            revoked: (bool) $row-\u003erevoked,\n            expiresAt: $row-\u003eexpires_at\n                ? new DateTimeImmutable($row-\u003eexpires_at)\n                : null,\n        );\n    }\n}\n```\n\n`TenantApiKeyRecord::isActive()` then handles expiry and revocation checks internally — the strategy throws `TenantNotFoundException` if it returns `false`.\n\n---\n\n## Wiring up the resolver\n\nBuild a `TenantResolverRegistry`, add strategies in priority order (higher number = tried first), then wrap it in `ChainTenantResolver`:\n\n```php\nuse Tenancy\\Resolution\\ChainTenantResolver;\nuse Tenancy\\Resolution\\TenantResolverRegistry;\nuse Tenancy\\Resolution\\Strategies\\SubdomainTenantResolutionStrategy;\nuse Tenancy\\Resolution\\Strategies\\ApiKeyTenantResolutionStrategy;\nuse Tenancy\\Support\\HostNormalizer;\n\n$normalizer = new HostNormalizer();\n$lookup     = new EloquentTenantLookup();\n\n$registry = new TenantResolverRegistry();\n$registry\n    -\u003eadd(new ApiKeyTenantResolutionStrategy($apiKeyLookup), priority: 20)\n    -\u003eadd(new SubdomainTenantResolutionStrategy($lookup, $normalizer, 'example.com'), priority: 10);\n\n$resolver = new ChainTenantResolver($registry);\n```\n\n---\n\n## Resolving a request\n\nBuild a `TenantResolutionInput` from the incoming request and call `resolve()`:\n\n```php\nuse Tenancy\\Resolution\\TenantResolutionInput;\n\n$input = TenantResolutionInput::fromArray([\n    'host'            =\u003e $request-\u003egetHost(),\n    'path'            =\u003e $request-\u003egetPathInfo(),\n    'headers'         =\u003e $request-\u003eheaders-\u003eall(),\n    'sessionTenantId' =\u003e $session-\u003eget('tenant_id'),\n    'userId'          =\u003e $auth-\u003eid(),\n]);\n\n$context = $resolver-\u003eresolve($input);  // throws on failure\n```\n\nThen store it in `CurrentTenant` for the duration of the request:\n\n```php\nuse Tenancy\\Context\\CurrentTenant;\n\n$currentTenant = new CurrentTenant();\n$currentTenant-\u003eset($context);\n\n// Later in the request lifecycle:\n$context   = $currentTenant-\u003eget();          // throws TenantNotResolvedException if not set\n$tenantId  = $currentTenant-\u003eget()-\u003erecord-\u003eid;\n$isSystem  = $currentTenant-\u003eget()-\u003eisSystem();\n```\n\n#### Lifecycle in long-running servers\n\nIn **PHP-FPM** every request runs in a fresh process, so `CurrentTenant` is naturally reset between requests.\n\nIn **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.\n\nAlways call `clear()` at the end of each request — typically in a terminating middleware:\n\n```php\n// Framework-agnostic terminating middleware example\npublic function terminate(): void\n{\n    $this-\u003ecurrentTenant-\u003eclear();\n}\n```\n\nIf your framework supports request-scoped bindings, binding `CurrentTenant` per-request is the cleanest solution and makes the manual `clear()` unnecessary.\n\n#### Running code under a specific tenant\n\nUse `scoped()` when you need to temporarily switch context — background jobs, data migrations, or console commands that iterate over tenants:\n\n```php\nforeach ($tenants as $tenantRecord) {\n    $context = new TenantContext(record: $tenantRecord, source: TenantResolutionSource::Console);\n\n    $currentTenant-\u003escoped($context, function () use ($currentTenant) {\n        // runs under $tenantRecord; previous context is restored on exit, even on exceptions\n        $this-\u003eprocessReports($currentTenant-\u003eget());\n    });\n}\n```\n\n#### Running code without any tenant context\n\nUse `withoutContext()` to temporarily drop the tenant for global or system-level operations:\n\n```php\n$currentTenant-\u003ewithoutContext(function () {\n    // no tenant is set here\n    $this-\u003esyncGlobalConfig();\n});\n// previous context is restored on exit\n```\n\n---\n\n## Checking access and permissions\n\n```php\nuse Tenancy\\Access\\TenantAccessGuard;\nuse Tenancy\\Authorization\\TenantPermissionChecker;\n\n$guard = new TenantAccessGuard(new EloquentMembershipRepository());\n$guard-\u003eensureAccess($userId, $context);  // throws TenantAccessDeniedException\n\n$checker = new TenantPermissionChecker(new EloquentTenantPermissionRepository());\n$checker-\u003eensureCan($userId, $context, 'posts.publish');  // throws TenantPermissionDeniedException\n```\n\n---\n\n## Available resolution strategies\n\n| Strategy | Reads from | Default header / key |\n|---|---|---|\n| `SubdomainTenantResolutionStrategy` | Subdomain of a configured base domain | — |\n| `CustomDomainTenantResolutionStrategy` | Full custom domain mapped to a tenant | — |\n| `PathTenantResolutionStrategy` | First URL path segment (or after a prefix) | — |\n| `HeaderTenantResolutionStrategy` | Request header (tenant ID) | `X-Tenant-ID` |\n| `HeaderTenantSlugResolutionStrategy` | Request header (tenant slug) | `X-Tenant-Slug` |\n| `SessionTenantResolutionStrategy` | Session value | — |\n| `ApiKeyTenantResolutionStrategy` | Bearer token, `X-API-Key` header, or explicit field | `Authorization` / `X-API-Key` |\n\n`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`.\n\n---\n\n## Exception hierarchy\n\n```\nTenantException\n└── TenantAuthorizationException\n│   ├── TenantAccessDeniedException\n│   └── TenantPermissionDeniedException\n└── TenantResolutionException\n    ├── TenantNotFoundException\n    ├── TenantNotResolvedException\n    ├── TenantResolutionConflictException\n    └── TenantSuspendedException\n```\n\n---\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, code conventions, and PR guidelines.\n\n---\n## License\n\n[MIT License](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farielespinoza07%2Ftenancy-core","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Farielespinoza07%2Ftenancy-core","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farielespinoza07%2Ftenancy-core/lists"}