›
byrcsc/laravel-custom-fields · 1.x
One resolver decides which tenant the package is acting for, and every definition and value is scoped to its answer.
Every read and write in the package runs against the definitions the current tenant can see. The same model under two tenants has two different sets of fields, and a key one tenant defined is unknown to the other.
Which tenant that is comes from one method:
namespace ByRcsc\LaravelCustomFields\Contracts;
interface TenantResolver
{
public function currentTenant(): ?string;
}A contract rather than an integration, so the package does not tie its releases to any one tenancy package, and your models do not grow a tenancy concern to satisfy it. Whatever already knows the answer implements this.
null means the application is not acting for any tenant. It is not a failure
and not a missing value.
It is what a single-tenant application returns always, and what a multi-tenant one returns from a console command, a queued job with no tenant made current, or a landlord request. Definitions written under it are global, and every tenant can see them.
This is what lets a single-tenant application use the whole package with every
tenant column null and no special mode anywhere in the code.
custom-fields.tenant_resolver defaults to null, which means detect:
| Situation | Resolver used | Effect |
|---|---|---|
spatie/laravel-multitenancy installed | SpatieTenantResolver | Fields are scoped per spatie tenant |
| It is not installed | NullTenantResolver | Every field is global |
| The config key names a class | That class | Whatever it returns |
Installing a tenancy package is a clear enough statement that the application has tenants. A spatie install left on the null resolver would quietly serve one tenant's fields to all of them, which is worse than a default you have to override to opt out of.
To opt out, name NullTenantResolver explicitly:
// config/custom-fields.php
'tenant_resolver' => ByRcsc\LaravelCustomFields\Tenancy\NullTenantResolver::class,That is a spatie application saying it wants one shared set of fields rather than a set per tenant.
SpatieTenantResolver reads the current tenant through
multitenancy.tenant_model rather than naming spatie's own Tenant class, so
an application with a custom tenant model gets the right answer. It returns the
tenant's primary key cast to a string, which is why the tenant column is a
string: an auto-incrementing id and a UUID both fit.
It returns null when no tenant is current, when the config names no tenant model, and when what it names is not one. None of those are errors the application could act on at that point, so they are treated as no tenant.
spatie/laravel-multitenancy is not a dependency of this package. Configure
this resolver only in an application that installs it.
Any other source of truth, a subdomain, a header, a column on the authenticated user, is a class with one method:
use ByRcsc\LaravelCustomFields\Contracts\TenantResolver;
final class SubdomainTenantResolver implements TenantResolver
{
public function currentTenant(): ?string
{
return request()->route('tenant');
}
}It is resolved from the container, so it may take constructor dependencies.
The binding is scoped, not a singleton. The resolver and the manager both hold per-request answers. A singleton would survive between Octane requests and between queued jobs, serving one job's tenant to the next one. If you bind a resolver yourself, keep it scoped.
A definition with no tenant is global. Every tenant sees it, alongside its own.
Where a tenant defines its own field under a key a global definition already uses, the tenant's field wins:
CustomFields::define(Customer::class, [
'key' => 'account_tier',
'type' => FieldType::Select,
'options' => ['standard', 'premium'],
]);
CustomFields::define(Customer::class, [
'key' => 'account_tier',
'type' => FieldType::Select,
'options' => ['standard', 'premium', 'enterprise'],
'tenant' => 'acme',
]);Acting for acme, account_tier includes the enterprise option. Acting for
any other tenant, or for none, it has only standard and premium. The rule
holds whichever order the two rows were written in.
Defining the same key twice for the same tenant is a duplicate and throws. Defining it once globally and once per tenant is the override, which is the point.
define() writes for the current tenant unless tenant says otherwise:
CustomFields::define(Customer::class, [
'key' => 'account_tier',
'type' => FieldType::Text,
'tenant' => 'acme',
]);That is what a seeder or a console command needs, since neither is acting for a
tenant. Passing 'tenant' => null writes a global field while a tenant is
current: the key present with a null value is a value, not an omission.
A tenant that is neither null nor a non-empty string throws
InvalidDefinitionException.
A global definition is shared. Its values are not.
The value rows for a record are stamped with the tenant that wrote them, and the
customFieldValues relation is scoped to the current tenant as well as to the
record. So one record can hold a different value of the same global field for
each tenant:
// acting for acme
$customer->setCustomField('reference', 'ACME-40');
// acting for globex
$customer->setCustomField('reference', 'GBX-11');
$customer->fresh()->getCustomField('reference'); // 'GBX-11'Two rows exist, and switching back to acme reads ACME-40. The unique index
on the values table includes the tenant for the same reason: every tenant can
see a global definition, but its records must not read or overwrite another
tenant's values.
That scoping runs through filtering as well, so
whereCustomField() on a global field matches only the current tenant's values.
Asking for a field the current tenant cannot see throws
UnknownCustomFieldException, on reads as well as writes, and the message says
which tenant was current:
No custom field [account_tier] is defined on [App\Models\Customer] while the current
tenant is [globex].Use CustomFields::definition(Customer::class, 'account_tier') when you want a null
instead, which is the one read path that answers with one.
define() accepts.