Forked from isocroft/singleDB_multiTenancy_in_Laravel.php
Created
December 29, 2025 07:50
-
-
Save nhalstead/118ffb74fe92e83edac285284b698cdc 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+
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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()); | |
| } | |
| } | |
| ?> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment