›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-custom-fields
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Defining fields
  • Field types and storage
  • Tenant scoping
  • Reading and writing values
  • Validation
  • Filtering records

Operations

  • Changing and deleting fields
  • Queries and eager loading
  • Events and listeners

Reference

  • Configuration
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Defining fields
  • Field types and storage
  • Tenant scoping
  • Reading and writing values
  • Validation
  • Filtering records

Operations

  • Changing and deleting fields
  • Queries and eager loading
  • Events and listeners

Reference

  • Configuration
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

byrcsc/laravel-custom-fields · 1.x

Tenant scoping.

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 is a real answer

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.

Which resolver you get

custom-fields.tenant_resolver defaults to null, which means detect:

SituationResolver usedEffect
spatie/laravel-multitenancy installedSpatieTenantResolverFields are scoped per spatie tenant
It is not installedNullTenantResolverEvery field is global
The config key names a classThat classWhatever 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.

The bundled spatie resolver

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.

Writing your own

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.

Global fields and the override rule

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.

Writing for another tenant

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.

Values are scoped too

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.

Reading an unknown key

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.

What to read next

  • Defining fields for the rest of what define() accepts.
  • Reading and writing values for how a value is stamped.
  • Testing for moving between tenants inside a test.
PreviousField types and storageNextReading and writing values
View source

On this page

  1. Null is a real answer
  2. Which resolver you get
  3. The bundled spatie resolver
  4. Writing your own
  5. Global fields and the override rule
  6. Writing for another tenant
  7. Values are scoped too
  8. Reading an unknown key
  9. What to read next