Skip to content

Commit 23b8fdc

Browse files
Merge pull request #48 from Relaticle/feat/custom-tenant-resolver
Feat/custom tenant resolver
2 parents a7dd882 + c1795a7 commit 23b8fdc

File tree

5 files changed

+200
-17
lines changed

5 files changed

+200
-17
lines changed

src/CustomFields.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
namespace Relaticle\CustomFields;
66

7+
use Closure;
78
use Relaticle\CustomFields\Models\CustomField;
89
use Relaticle\CustomFields\Models\CustomFieldOption;
910
use Relaticle\CustomFields\Models\CustomFieldSection;
1011
use Relaticle\CustomFields\Models\CustomFieldValue;
12+
use Relaticle\CustomFields\Services\TenantContextService;
1113

1214
final class CustomFields
1315
{
@@ -150,4 +152,25 @@ public static function useSectionModel(string $model): static
150152

151153
return new self;
152154
}
155+
156+
/**
157+
* Register a custom tenant resolver callback.
158+
*
159+
* This allows developers to provide their own tenant resolution logic
160+
* when they extend the CustomField models with custom tenant handling.
161+
*
162+
* The resolver will be called whenever the package needs to determine
163+
* the current tenant context (validation, queries, scopes, etc.).
164+
*
165+
* Example:
166+
* ```
167+
* CustomFields::resolveTenantUsing(fn() => auth()->user()?->company_id);
168+
* ```
169+
*
170+
* @param Closure(): (int|string|null) $callback
171+
*/
172+
public static function resolveTenantUsing(Closure $callback): void
173+
{
174+
TenantContextService::setTenantResolver($callback);
175+
}
153176
}

src/Filament/Management/Schemas/FieldForm.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Relaticle\CustomFields\Filament\Management\Forms\Components\TypeField;
3232
use Relaticle\CustomFields\Filament\Management\Forms\Components\VisibilityComponent;
3333
use Relaticle\CustomFields\Models\CustomField;
34+
use Relaticle\CustomFields\Services\TenantContextService;
3435

3536
class FieldForm implements FormInterface
3637
{
@@ -85,7 +86,7 @@ public static function schema(bool $withOptionsRelationship = true): array
8586
array $data
8687
): array {
8788
if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) {
88-
$data[config('custom-fields.database.column_names.tenant_foreign_key')] = Filament::getTenant()?->getKey();
89+
$data[config('custom-fields.database.column_names.tenant_foreign_key')] = TenantContextService::getCurrentTenantId();
8990
}
9091

9192
return $data;
@@ -176,7 +177,7 @@ public static function schema(bool $withOptionsRelationship = true): array
176177
config(
177178
'custom-fields.database.column_names.tenant_foreign_key'
178179
),
179-
Filament::getTenant()?->getKey()
180+
TenantContextService::getCurrentTenantId()
180181
)
181182
)
182183
)
@@ -236,7 +237,7 @@ public static function schema(bool $withOptionsRelationship = true): array
236237
config(
237238
'custom-fields.database.column_names.tenant_foreign_key'
238239
),
239-
Filament::getTenant()?->getKey()
240+
TenantContextService::getCurrentTenantId()
240241
)
241242
)
242243
)

src/Filament/Management/Schemas/SectionForm.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace Relaticle\CustomFields\Filament\Management\Schemas;
66

7-
use Filament\Facades\Filament;
87
use Filament\Forms\Components\Select;
98
use Filament\Forms\Components\Textarea;
109
use Filament\Forms\Components\TextInput;
@@ -18,6 +17,7 @@
1817
use Relaticle\CustomFields\Enums\CustomFieldSectionType;
1918
use Relaticle\CustomFields\Enums\CustomFieldsFeature;
2019
use Relaticle\CustomFields\FeatureSystem\FeatureManager;
20+
use Relaticle\CustomFields\Services\TenantContextService;
2121

2222
class SectionForm implements FormInterface, SectionFormInterface
2323
{
@@ -55,7 +55,7 @@ public static function schema(): array
5555
config(
5656
'custom-fields.database.column_names.tenant_foreign_key'
5757
),
58-
Filament::getTenant()?->getKey()
58+
TenantContextService::getCurrentTenantId()
5959
)
6060
)
6161
->where('entity_type', self::$entityType)
@@ -97,7 +97,7 @@ public static function schema(): array
9797
config(
9898
'custom-fields.database.column_names.tenant_foreign_key'
9999
),
100-
Filament::getTenant()?->getKey()
100+
TenantContextService::getCurrentTenantId()
101101
)
102102
)
103103
->where('entity_type', self::$entityType)

src/Services/TenantContextService.php

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,26 @@
44

55
namespace Relaticle\CustomFields\Services;
66

7+
use Closure;
78
use Filament\Facades\Filament;
89
use Illuminate\Support\Facades\Context;
910

1011
final class TenantContextService
1112
{
1213
private const string TENANT_ID_KEY = 'custom_fields_tenant_id';
1314

15+
/**
16+
* Custom tenant resolver callback.
17+
*
18+
* @var Closure(): (int|string|null)|null
19+
*/
20+
private static ?Closure $tenantResolver = null;
21+
1422
/**
1523
* Set the tenant ID in the context.
1624
* This will persist across queue jobs and other async operations.
1725
*/
18-
public static function setTenantId(null|int|string $tenantId): void
26+
public static function setTenantId(null | int | string $tenantId): void
1927
{
2028
if ($tenantId !== null) {
2129
Context::addHidden(self::TENANT_ID_KEY, $tenantId);
@@ -25,24 +33,49 @@ public static function setTenantId(null|int|string $tenantId): void
2533
}
2634

2735
/**
28-
* Get the current tenant ID from context or Filament.
36+
* Register a custom tenant resolver.
37+
*
38+
* @param Closure(): (int|string|null) $callback
39+
*/
40+
public static function setTenantResolver(Closure $callback): void
41+
{
42+
self::$tenantResolver = $callback;
43+
}
44+
45+
/**
46+
* Clear the custom tenant resolver.
47+
*/
48+
public static function clearTenantResolver(): void
49+
{
50+
self::$tenantResolver = null;
51+
}
52+
53+
/**
54+
* Get the current tenant ID from custom resolver, context, or Filament.
2955
* This works in both web requests and queue jobs.
56+
*
57+
* Resolution order:
58+
* 1. Custom resolver (if registered)
59+
* 2. Laravel Context (works in queues)
60+
* 3. Filament tenant (works in web requests)
61+
* 4. null (no tenant)
3062
*/
31-
public static function getCurrentTenantId(): null|int|string
63+
public static function getCurrentTenantId(): null | int | string
3264
{
33-
// First try to get tenant from Laravel Context (works in queues)
65+
// First priority: custom resolver
66+
if (self::$tenantResolver !== null) {
67+
return (self::$tenantResolver)();
68+
}
69+
70+
// Second priority: Laravel Context (works in queues)
3471
$contextTenantId = Context::getHidden(self::TENANT_ID_KEY);
3572
if ($contextTenantId !== null) {
3673
return $contextTenantId;
3774
}
3875

39-
// Fallback to Filament tenant (works in web requests)
76+
// Third priority: Filament tenant (works in web requests)
4077
$filamentTenant = Filament::getTenant();
41-
if ($filamentTenant !== null) {
42-
return $filamentTenant->getKey();
43-
}
44-
45-
return null;
78+
return $filamentTenant?->getKey();
4679
}
4780

4881
/**
@@ -60,7 +93,7 @@ public static function setFromFilamentTenant(): void
6093
/**
6194
* Execute a callback with a specific tenant context.
6295
*/
63-
public static function withTenant(null|int|string $tenantId, callable $callback): mixed
96+
public static function withTenant(null | int | string $tenantId, callable $callback): mixed
6497
{
6598
$originalTenantId = self::getCurrentTenantId();
6699

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Support\Facades\Context;
6+
use Relaticle\CustomFields\CustomFields;
7+
use Relaticle\CustomFields\Models\CustomField;
8+
use Relaticle\CustomFields\Models\CustomFieldSection;
9+
use Relaticle\CustomFields\Services\TenantContextService;
10+
use Relaticle\CustomFields\Tests\Fixtures\Models\User;
11+
12+
beforeEach(function (): void {
13+
// Clean up any previous resolver
14+
TenantContextService::clearTenantResolver();
15+
});
16+
17+
afterEach(function (): void {
18+
// Always clean up after tests
19+
TenantContextService::clearTenantResolver();
20+
Context::flush();
21+
});
22+
23+
describe('Custom Tenant Resolver', function (): void {
24+
it('allows registering a custom tenant resolver', function (): void {
25+
$expectedTenantId = 42;
26+
27+
CustomFields::resolveTenantUsing(fn () => $expectedTenantId);
28+
29+
expect(TenantContextService::getCurrentTenantId())->toBe($expectedTenantId);
30+
});
31+
32+
it('custom resolver takes priority over Laravel Context', function (): void {
33+
$contextTenantId = 100;
34+
$customTenantId = 200;
35+
36+
// Set Laravel Context
37+
TenantContextService::setTenantId($contextTenantId);
38+
39+
// Register custom resolver
40+
CustomFields::resolveTenantUsing(fn () => $customTenantId);
41+
42+
// Custom resolver should win
43+
expect(TenantContextService::getCurrentTenantId())->toBe($customTenantId);
44+
});
45+
46+
it('falls back to Laravel Context when no custom resolver is set', function (): void {
47+
$tenantId = 150;
48+
49+
TenantContextService::setTenantId($tenantId);
50+
51+
expect(TenantContextService::getCurrentTenantId())->toBe($tenantId);
52+
});
53+
54+
it('returns null when no tenant context is available', function (): void {
55+
expect(TenantContextService::getCurrentTenantId())->toBeNull();
56+
});
57+
58+
it('can clear custom resolver', function (): void {
59+
CustomFields::resolveTenantUsing(fn () => 999);
60+
61+
expect(TenantContextService::getCurrentTenantId())->toBe(999);
62+
63+
TenantContextService::clearTenantResolver();
64+
65+
expect(TenantContextService::getCurrentTenantId())->toBeNull();
66+
});
67+
68+
it('works with dynamic tenant resolution based on auth', function (): void {
69+
auth()->logout();
70+
71+
$user = User::factory()->create();
72+
73+
CustomFields::resolveTenantUsing(fn () => auth()->user()?->id);
74+
75+
// Before login
76+
expect(TenantContextService::getCurrentTenantId())->toBeNull();
77+
78+
// After login
79+
$this->actingAs($user);
80+
expect(TenantContextService::getCurrentTenantId())->toBe($user->id);
81+
});
82+
});
83+
84+
describe('Tenant Resolver with Context Service', function (): void {
85+
it('resolver can access closure variables', function (): void {
86+
$companyId = 999;
87+
88+
CustomFields::resolveTenantUsing(fn () => $companyId);
89+
90+
expect(TenantContextService::getCurrentTenantId())->toBe($companyId);
91+
});
92+
93+
it('resolver can be changed dynamically', function (): void {
94+
$tenant1 = 111;
95+
$tenant2 = 222;
96+
97+
CustomFields::resolveTenantUsing(fn () => $tenant1);
98+
expect(TenantContextService::getCurrentTenantId())->toBe($tenant1);
99+
100+
CustomFields::resolveTenantUsing(fn () => $tenant2);
101+
expect(TenantContextService::getCurrentTenantId())->toBe($tenant2);
102+
});
103+
104+
it('resolver can return string tenant IDs', function (): void {
105+
$tenantUuid = 'org_12345';
106+
107+
CustomFields::resolveTenantUsing(fn () => $tenantUuid);
108+
109+
expect(TenantContextService::getCurrentTenantId())->toBe($tenantUuid);
110+
});
111+
112+
it('resolver can return null for no tenant', function (): void {
113+
CustomFields::resolveTenantUsing(fn () => null);
114+
115+
expect(TenantContextService::getCurrentTenantId())->toBeNull();
116+
});
117+
118+
it('handles resolver exceptions gracefully', function (): void {
119+
CustomFields::resolveTenantUsing(function () {
120+
throw new \RuntimeException('Tenant resolution failed');
121+
});
122+
123+
expect(fn () => TenantContextService::getCurrentTenantId())
124+
->toThrow(\RuntimeException::class, 'Tenant resolution failed');
125+
});
126+
});

0 commit comments

Comments
 (0)