Skip to main content
In 3D mode, three distinct models represent three distinct concepts: the human who authenticated (Identity), the tenant-scoped role or membership they are acting as (Principal), and the isolation boundary they operate within (Tenant). The same guards that power 2D mode support this shape without any driver changes — only your models and resolver wiring differ.
When an access token carries a pid claim, the guard resolves exactly that principal and fails closed if it cannot. A token that claims pid = 42 but resolves to a different principal — or no principal at all — is rejected rather than silently downgraded. This prevents privilege-escalation across tenant boundaries.
1

Create the Identity model

The identity represents the human account. It implements Identity, HasDevices (for refresh-token rotation), and HasPrincipals (to expose the principals it may act as). Optionally implement ResolvesHintedPrincipal to short-circuit the default principals()->find($hint) lookup with a more efficient joined query.
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\User;
use SineMacula\Laravel\Authentication\Contracts\HasDevices;
use SineMacula\Laravel\Authentication\Contracts\HasPrincipals;
use SineMacula\Laravel\Authentication\Contracts\Identity;
use SineMacula\Laravel\Authentication\Contracts\Principal as PrincipalContract;
use SineMacula\Laravel\Authentication\Contracts\ResolvesHintedPrincipal;
use SineMacula\Laravel\Authentication\Models\Device;
use SineMacula\Laravel\Authentication\Traits\Authenticatable;

class AppIdentity extends User implements Identity, HasDevices, HasPrincipals, ResolvesHintedPrincipal
{
    use Authenticatable;

    /**
     * Returns the principals this identity is permitted to act as.
     * The package's default resolver calls ->find($hint) when the
     * access token carries a `pid` claim, and resolveDefaultPrincipal()
     * otherwise.
     */
    public function principals(): HasMany
    {
        return $this->hasMany(AppMembership::class, 'identity_id');
    }

    public function devices(): MorphMany
    {
        return $this->morphMany(Device::class, 'authenticatable');
    }

    public function resolveDefaultPrincipal(): ?PrincipalContract
    {
        return $this->principals()->where('is_active', true)->first();
    }

    /**
     * Optional: when a JWT carries a `pid`, resolve the hinted principal
     * directly. Use this hook to perform a joined lookup and pre-hydrate
     * the tenant relation so Auth::tenant() does not trigger a separate query.
     */
    public function resolveHintedPrincipal(mixed $hint): ?PrincipalContract
    {
        return AppMembership::query()
            ->join('app_tenants', 'app_tenants.id', '=', 'app_memberships.tenant_id')
            ->where('app_memberships.identity_id', $this->getKey())
            ->where('app_memberships.id', $hint)
            ->select('app_memberships.*')
            ->first();
    }
}
resolveDefaultPrincipal() is called when the token carries no pid hint — typically on first login before you embed the principal id into the token. resolveHintedPrincipal() is the preferred seam for optimising the hot bearer path: perform the joined lookup you need, attach the tenant relation you want Auth::tenant() to read, and set the inverse identity relation when appropriate.
2

Create the Principal model

The principal represents the tenant-scoped actor — typically a membership or role row. It implements Principal and belongs to a Tenant. The ActsAsPrincipal trait provides default getPrincipalIdentifier(), getIdentity(), getTenant(), and isActive() implementations sourced from conventional attribute and relation names (id, identity, tenant, is_active).
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use SineMacula\Laravel\Authentication\Contracts\Principal as PrincipalContract;
use SineMacula\Laravel\Authentication\Traits\ActsAsPrincipal;

class AppMembership extends Model implements PrincipalContract
{
    use ActsAsPrincipal;

    protected $fillable = ['identity_id', 'tenant_id', 'role', 'is_active'];

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(AppTenant::class);
    }
}
ActsAsPrincipal reads is_active for the isActive() check and follows the tenant relation for getTenant(). Override getActiveAttributeName() or getTenantRelationName() if your schema uses different column or relation names.
3

Create the Tenant model

The tenant is the isolation boundary the principal acts within. It implements Tenant so Auth::tenant() can return it. Optionally implement HasType and use ProvidesTenantType to expose a categorical label (Auth::type()) — useful when a single app serves multiple audience types such as 'staff' and 'customer'.
use Illuminate\Database\Eloquent\Model;
use SineMacula\Laravel\Authentication\Contracts\HasType;
use SineMacula\Laravel\Authentication\Contracts\Tenant as TenantContract;
use SineMacula\Laravel\Authentication\Traits\ActsAsTenant;
use SineMacula\Laravel\Authentication\Traits\ProvidesTenantType;

class AppTenant extends Model implements HasType, TenantContract
{
    use ActsAsTenant, ProvidesTenantType;
}
ProvidesTenantType reads the type column by default and resolves BackedEnum via value, UnitEnum via name, and any other value via string cast. Override getTenantTypeName() if your column is named differently. Drop HasType and ProvidesTenantType entirely if your app does not need tenant type discrimination.
4

Register the guard in config/auth.php

The guard registration is identical to 2D mode. Point the provider at your identity model.
// config/auth.php
'guards' => [
    'api' => [
        'driver'   => 'jwt',
        'provider' => 'users',
    ],
],

'providers' => [
    'users' => [
        'driver' => 'model',
        'model'  => App\Models\AppIdentity::class,
    ],
],
5

Understand what each accessor returns

Once a request carries a valid bearer token the guard rehydrates and binds all three layers. Each accessor reflects a distinct model:
use SineMacula\Laravel\Authentication\Facades\Auth;

Auth::identity();    // AppIdentity — the human who authenticated
Auth::principal();   // AppMembership — the tenant-scoped actor
Auth::tenant();      // AppTenant — resolved via $membership->tenant()
Auth::type();        // 'staff' | 'customer' | null — from AppTenant::getType()
Auth::device();      // Device|null — the issuing device
Auth::user();        // AppIdentity — same as Auth::identity()
Auth::tenant() follows the tenant relation on the resolved principal. If you pre-hydrate the relation inside resolveHintedPrincipal(), no additional query is issued.

Custom PrincipalResolver

The package ships a DefaultPrincipalResolver that calls resolveDefaultPrincipal() for hint-free requests and resolveHintedPrincipal() (or principals()->find($hint)) when a pid is present. This covers most 3D apps. When you need a domain-specific resolution strategy — subdomain-scoped tenancy, a custom request header, a session claim, or any logic that cannot live on the identity model — implement the PrincipalResolver contract and bind it in a service provider:
use SineMacula\Laravel\Authentication\Contracts\PrincipalResolver;

// AppServiceProvider or a dedicated AuthServiceProvider
$this->app->singleton(PrincipalResolver::class, MyTenantScopedResolver::class);
All guards without a local principal_resolver override in config/auth.php will use this binding for both bearer-token resolution and refresh exchange. Guards that declare their own resolver take precedence:
// config/auth.php — per-guard override takes highest precedence
'guards' => [
    'staff' => [
        'driver'             => 'jwt',
        'provider'           => 'users',
        'principal_resolver' => App\Auth\Resolvers\StaffPrincipalResolver::class,
    ],
],
The full precedence order is:
  1. auth.guards.<name>.principal_resolver (per-guard config)
  2. The app-wide PrincipalResolver::class container binding
  3. The package default DefaultPrincipalResolver