Skip to content

Instantly share code, notes, and snippets.

@isocroft
Last active December 29, 2025 07:50
Show Gist options
  • Select an option

  • Save isocroft/b6106bcd3e7c89225b9067ad00b9b69f to your computer and use it in GitHub Desktop.

Select an option

Save isocroft/b6106bcd3e7c89225b9067ad00b9b69f to your computer and use it in GitHub Desktop.
How to properly setup tenant scoping for routes and models (by centralizing the logic for scoping) in Laravel v11.x+, v12.x+ and v13.x+
<?php
namespace Parlie\Traits\Routing\Middleware\Guard;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
trait ApplyTenantRouteGuard {
abstract public function guardCallback (Request $request): void;
/**
* Handle an incoming request
*
* @param \Illuminate\Http\Request $request
* @param \Closure(Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
final protected function handle (Request $request, Closure $next): Response {
if (!auth()->check()) {
$errorResponse = $request->accepts(['application/json'])
? response()->json(config('palie.route_guard.json_error'), 403);
: response(config('palie.route_guard.text_error'), 403);
return $errorResponse;
}
$loggedInUser = auth()->user();
/* @NOTE: HTTP request cookie MUST be Http-Only and Same-Site: `strict` */
/* @HINT: This specific cookie MUST also be encrypted before including it in a HTTP response */
if ($loggedInUser->getTenantId() === $request->cookie($loggedInUser->getTenantColumnKey())) {
$this->guardCallback($request);
return $next($request);
}
return $errorResponse;
}
}
/*
namespace App\Http\Middleware;
use Parlie\Traits\Routing\Middleware\Guard\ApplyTenantRouteGuard;
use Illuminate\Http\Request;
use App\Models\Scopes\MyCustomScope;
use Illuminate\Database\Eloquent\Builder;
use App\Models\Organization;
class MyMiddleware {
use ApplyTenantRouteGuard;
public function guardCallback (Request $request): void {
Organization::addGlobalScope(new MyCustomScope($request));
Organization::addGlobalScope('country', function(Builder $builder) {
$country = session('location.from.ip.as.country');
$builder->where('country', $country);
});
}
}
*/
?>
<?php
namespace Parlie\Traits\Routing\ServiceProviderBindings;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
//use Illuminate\Support\Facades\Route;
use Illuminate\Routing\Router;
/* @NOTE: Add this trait to the `RouteServiceProvider` class */
trait ApplyTenantRouteModelBinding {
//private const MODELS_MAP = config('palie.models_map');
private function fallbackExitCallback () {
throw new NotFoundHTTPException;
}
public function bootRouteModelBindings(Router $router): void {
$MODELS_MAP = config('palie.models_map', []);
/*Route::pattern(
config('palie.tenants.mirrors.id_name'),
config('palie.tenants.mirrors.id_type') === 'cuid_v2'
? '[a-z0-9]+'
: '[-a-fA-F0-9]+'
);*/
$router->pattern(
config('palie.tenants.mirrors.id_name'),
config('palie.tenants.mirrors.id_type', 'cuid_v2') === 'cuid_v2'
? '[a-z0-9]+'
: '[-a-fA-F0-9]+'
);
foreach ($MODELS_MAP as $parameter => $fullQualifiedClassNameOrCallback) {
/*Route::model(
$parameter,
$fullQualifiedClassNameOrCallback,
array($this, 'fallbackExitCall')
);*/
$router->model(
$parameter,
$fullQualifiedClassNameOrCallback,
array($this, 'fallbackExitCallback')
);
}
}
}
?>
<?php
namespace Parlie\Models\Scopes;
use Parlie\Models\Base\TenantModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Scope;
use Illiminate\Http\Request;
//use Illuminate\Support\Facades\Auth;
class TenantScope implements Scope {
protected $request;
public function __construct (Request $request = null) {
/* @CHECK: https://laravel.com/docs/5.4/helpers#method-request */
$this->request = $request !== null ? $request : request();
}
public function __destruct () {
$this->request = null;
}
public function applyTenantFilterClause (string $tenantColumnName, string $tenantTableName, Builer $builder):void {
/* if (!Auth::hasUser()) {*/
if (auth()->guest()) {
return;
}
$tenantId = $this->request->cookie($tenantColumnName);
if ($tenantId) {
$builder->where($tenantColumnName, $tenantId);
return;
}
throw new \Exception("'tenant id' not found to scope model query for table: `" . $tenantTableName . "`");
}
public function apply (Builder $builder, TenantModel $model): void {
$tenantColumnName = $model->getTenantColumnKey();
$tenantTableName = $model->getTable();
$this->applyTenantFilterClause($tenantColumnName, $tenantTableName, $builder);
}
}
?>
<?php
namespace Parlie\Models\Base;
use Illuminate\Database\Eloquent\Model;
use Visus\Cuid2\Cuid2;
trait HasTenantHelpers {
abstract public function getTenantColumnKey(): string;
abstract public function getTenantOwnerColumnKey(): string;
final protected function getTenantOwnerColumnName(): string {
return $this->getTenantOwnerColumnKey();
}
final protected function getTenantColumnName (): string {
return $this->getTenantColumnKey();
}
public function getTenantId(): string {
$tenantId = $this->{$this->getTenantColumnName()};
$tenantId = (string) $tenantId;
return $tenantId;
}
}
abstract class TenantModel extends Model {
use HasTenantHelpers;
protected static function boot(): void {
parent::boot();
}
protected static function booting(): void {
/* @TODO: ... */
}
protected static function booted(): void {
if (!static::hasGlobalScope('Parlie\Models\Scopes\TenantScope')) {
/* @NOTE:
The below won't work in Laravel 5.x+, 6.x+, 7.x+ and 8.x as
it is required to pass the instance of the `Scope`interface
in these versions of Laravel.
We can't pass the instance of `Scope` interface here because
it is going to be a circular dependency!!!
*/
static::addGlobalScope('Parlie\Models\Scopes\TenantScope');
}
static::creating(function (self $tenantModel) {
$tenantOwnerIdColumnName = $tenantModel->getTenantOwnerColumnName();
if ($tenantOwnerIdColumnName !== '_') {
if (!array_key_exists($tenantOwnerIdColumnName, $tenantModel->attributes)) {
$tenantModel->setAuthUserId(auth()->id());
}
}
});
static::saving(function (self $tenantModel) {
$tenantIdColumnName = $tenantModel->getTenantColumnName();
$tenantSlugIdColumnName = $tenantModel->getRouteKeyName();
if (!isset($tenantModel->{$tenantIdColumnName})) {
$tenantId = auth()-user()->getTenantId();
$tenantModel->attributes[$tenantIdColumnName] = $tenantId;
}
if (!isset($tenantModel->{$tenantSlugIdColumnName})) {
$cuid = new Cuid2(20);
$tenantModel->attributes[$tenantSlugIdColumnName] = $cuid->toString();
}
});
// @TODO: do things with Laravel config/stuff here...
}
final public static function builderWithoutTenantScoping () {
return static::withoutGlobalScope('Parlie\Models\Scopes\TenantScope');
}
final public function setAuthUserId($id): bool {
$ownerColumnName = $this->getTenantOwnerColumnName();
if (isset($id) and $ownerColumnName !== '_') {
$this->attributes[$ownerColumnName] = $id;
return true;
}
return false;
}
/* @Override (from Laravels' `Model` abstract class) */
public function getRouteKeyName () {
/* @HINT: Value is generated using either cuid_v2 or uuid_v4 */
return config('palie.tenants.mirrors.id_name');
}
public function relatedToTenantWithId($id): bool {
return isset($id) and is_string($id)
? $id === $this->{$this->getTenantColumnName()}
: false;
}
/* @Override (from Laravels' `Model` abstract class) */
public function resolveRouteBinding($value, $field = null) {
return $this->where($this->getRouteKeyName(), $value)->firstOrFail();
}
public function getTenantOwnerColumnKey(): string {
return '_';
}
}
?>
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('tenant_guard:api')->domain('my.{account}.saavylytics.com')->prefix('/api/{tenant}')->group(function() {
Route::get('/students', function(Organization $org) {})->name('')->withoutScopedBindings();
Route::get('/courses', function(Organization $org) {})->name('');
});
?>
<?php
namespace App\Traits;
trait HasSuspension {
abstract public function getUserActiveColumnName(): string;
abstract public function getUserActiveColumnType(): string;
final protected function scopeAsSuspended (Builder $builder) {
return $builder->where($this->getUserActiveColumnName(), $this->getValue());
}
final protected function scopeAsNotSuspended (Builder $builder) {
return $builder->where($this->getUserActiveColumnName(), $this->getValue(true));
}
private function getValue ($switchOff = false) {
$value = $switchOff ? 0 : 1;
$flagDataType = $this->getUserActiveColumnType();
if ($dataType === "bool") {
$value = $switchOff ? false : true;
}
return $value;
}
public function suspend(): bool {
$this->{$this->getUserActiveColumnType()} = $this->getValue();
return $this->save();
}
public function restoreFromSuspension(): bool {
$this->{$this->getUserActiveColumnType()} = $this->getValue(true);
return $this->save();
}
public function isSuspended() {
return $this->{$this->getUserActiveColumnName()};
}
}
?>
<?php
namespace App\Models;
/* composer require dyrynda/laravel-model-uuid v8.2.0 */
/* composer require robinvdvleuten/ulid v5.0.0 */
use Dyrynda\Database\Support\Casts\EfficientUuid;
use Dyrynda\Database\Support\GeneratesUuid;
use Parlie\Models\Base\TenantModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Organisation;
use App\Traits\HasSuspension;
class User extends TenantModel {
use GeneratesUuid, HasSuspension;
public $incrementing = false;
protected $keyType = /* \ulid */ 'binary';
protected $casts = [
'slug_id' => EfficientUuid::class,
'tenant_id' => custom::BinaryToUlidString::class,
'id' => custom::BinaryToUlidString::class
];
public function getTenantColumnKey(): string {
return config('palie.tenants.column_key');
}
public function getTenantOwnerColumnKey(): string {
return $this->getKeyName();
}
public function getUserActiveColumnName(): string {
return 'is_active';
}
public function getUserActiveColumnType(): string {
return 'bool';
}
public function uuidColumn(): string {
return config('palie.tenants.mirrors.id_name');
}
public function tenant(): BelongsTo {
return $this->belongsTo(Organization::class);
}
}
?>
<?php
namespace Palie\Relations;
use Illuminate\Database\Eloquent\Relations\HasMany;
/* @CHECK: https://github.com/laravel/framework/issues/54867#issuecomment-2694103119 */
class BinaryHasMany extends HasMany {
public function addEagerConstraints(array $models) {
$keys = $this->getEagerModelKeys($models);
if ($this->parent->getKeyType() === 'binary') {
$keys = array_map(function($key) {
/* @NOTE:
each `$key` is already binary here from `->getRawOriginal(..)`
> within `->getEagerModelKeys(..)`
*/
return $key;
}, $keys);
}
$whereIn = $this->whereInMethod($this->parent, $this->localKey);
if (!empty($keys)) {
$this->whereInEager($whereIn, $this->foreignKey, $keys, $this->getRelationQuery());
}
}
protected function getEagerModelKeys(array $models) {
$keys = [];
foreach ($models as $model) {
if (!is_null($value = $model->getRawOriginal($this->localKey))) {
/* @HINT: Fetch raw binary value before casting */
$keys[] = $value;
}
}
return array_values(array_unique($keys));
}
}
?>
<?php
namespace App\Models;
use Parlie\Models\Base\TenantModel;
use Palie\Relations\BinaryHasMany;
use App\Models\User;
class Organization extends TenantModel {
public $incrementing = false;
protected $keyType = /* \ulid */ 'binary';
protected $casts = [
'slug_id' => EfficientUuid::class,
'id' => custom::BinaryToUlidString::class
];
public function getTenantColumnKey(): string {
return $this->getKeyName();
}
public function users(): BinaryHasMany {
return $this->hasMany(User::class, config('palie.tenants.column_key'), $this->getKeyName());
}
}
?>
@isocroft
Copy link
Author

isocroft commented Oct 22, 2025

<?php

/* file: laravel-config.php */
/* Require this file once */

/* Laravel Config */
$config = [
  'tenants' => [
    'mirrors' => [
      'id_name' => 'slug_id',
      'id_type' => 'uuid_v4' // 'uuid_v4', 'cuid_v2'
     ],
     'column_key' => 'tenant_id'
  ],
  'route_guard' => [
    'json_error' => [
      'status' => 'failure',
      'data' => NULL,
      'message' => 'Access Denied',
      'meta' => []
    ],
    'text_error' => 'Access Denied'
  ],
  'models_map' => [
    'tenant' => 'App\\Models\\Organization'
  ]
];

return $config;

?>
<?php

/* file: laravel-request.php */
/* Require this file once */

 use Illuminate\Http\Request;

 return Request::create('/test-route');
?>
<?php

/* file: laravel-response.php */
/* Require this file once */

 use Illuminate\Http\Response;

  return Response::create();
?>
<?php

/* file: laravel_eloquent_user_attributes.php */
/* Require this file once */

  $userAttributes = [
    'name' => 'John Doe',
    'email' => 'johndoe@gmail.com'
  ];
  
  return $userAttributes;
?>
<?php

/**
   @NOTE: When writing tests for a laravel package, we may not need the following i we do not wish to boot a laravel app to run tests

    'Illuminate\Foundation\Testing\RefreshDatabase'
    'Illuminate\Foundation\Testing\TestCase\CreatesApplication'
    'Illuminate\Foundation\Testing\WithFaker'

    Rather we might require test-double (fakes) that have minimal build-up & tear-own time
*/






function mapUserAttributes(array $userAttributes, object $user): object
{
    foreach ($userAttributes as $key => $value) {
        $user->{$key} = $value;
    }
    return $user;
}

$config = require_once('laravel-config.php');
$request = require_once('laravel_request');
$response = require_once('laravel_response');
$userAttributes = require_once('laravel_eloquent_user_attributes');


if (!function_exists('config')) {
   /**
    * Retrieves a value from the global $config array using standard hierarchical dot-notation lookup.
    *
    * This function handles deep traversal (e.g., 'tenants.mirrors.id_name'). It also handles a common
    * case where an unnecessary file prefix is included (e.g., 'palie' in 'palie.models_map') by
    * skipping the first segment if it is not a valid root key.
    *
    * @param string $key The dot-notation configuration key (e.g., 'palie.tenants.mirrors.id_name').
    * @return mixed The configuration value or null if the key path is not found.
    */
   function config(string $key): mixed
   {
    // Access the global configuration array defined above.
    global $config;

    $path = explode('.', $key);
    $current = $config;

    // Heuristic: If the first key segment does not exist at the root, assume it's an
    // unnecessary prefix (like 'palie') and shift the path array to start from the next key.
    if (!isset($current[$path[0]]) && count($path) > 1) {
        array_shift($path); // Remove the first non-matching segment (e.g., 'palie')
    }

    // Traverse the configuration array using the remaining path segments.
    foreach ($path as $part) {
        if (is_array($current) && isset($current[$part])) {
            $current = $current[$part];
        } else {
            // Path segment not found.
            return null;
        }
    }

      return $current;
   }
}


$user = new stdClass();
$user->{'incrementing'} = true;

$updatedUser = mapUserAttributes($userAttributes, $user);

$updatedUser->save = function($name) {
    return true;
};

$updatedUser->getTableName = function() {
    return 'users';
};

$updatedUser->getUserActiveColumnType = function() {
   return 'bool';
};

$updatedUser->getTenantColumnKey = function() {
  return config('palie.tenants.column_key');
};

$updatedUser->getKeyName = function() {
  return '';
};

$updatedUser->getRouteKeyName = function() {
   return config('palie.tenants.mirrors.id_name');
};

$updatedUser->getUserActiveColumnName = function() {
   return 'is_active';
};

if (!function_exists('request')) {
   function request () {
     return $request;
  }
}

if (!function_exists('auth')) {
  /* @TODO: Needs a custom re-implementation of `Illuminate\Auth\AuthManager.php` */
  function auth() {

  }
}

/**
In feature tests, `$request->setUserResolver()` helps mock the authenticated user 
without the need for actingAs() or database persistence if not necessary for the 
specific test scenario. 
 */
$request->setUserResolver(function ($guard = null) {
    return auth()->guard($guard)->user();
});

if (!function_exists('response')) {
   function response () {
     return $response;
  }
}

if (!function_exists('session')) {}

if (!function_exists('redirect')) {}

if (!function_exists('event')) {}

?>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment