skip to Main Content

I’m really having trouble to understand how things must be implemented in order to work together. Let me start with the Filament Multi-tenancy.

I have Organizations, they’re related to Users with Many to Many relation. I want each organization to be responsible for it’s own resources and records, but I also want to have a Admin Organization that can manage all other organizations or atleast impersonate them (This will be easy to implement I guess once I get to it.)

The problem here is the following:

  1. I have the Organization Resource where I want people to create, edit or delete their own organizations scopes to the currently active Tenant.

I have the following OrganizationResource

<?php

namespace AppFilamentResources;

use AppFilamentResourcesOrganizationResourcePages;
use AppModelsOrganization;
use FilamentFormsComponentsDatePicker;
use FilamentFormsComponentsPlaceholder;
use FilamentFormsComponentsTextInput;
use FilamentFormsForm;
use FilamentResourcesResource;
use FilamentTablesActionsBulkActionGroup;
use FilamentTablesActionsDeleteAction;
use FilamentTablesActionsDeleteBulkAction;
use FilamentTablesActionsEditAction;
use FilamentTablesActionsForceDeleteAction;
use FilamentTablesActionsForceDeleteBulkAction;
use FilamentTablesActionsRestoreAction;
use FilamentTablesActionsRestoreBulkAction;
use FilamentTablesColumnsTextColumn;
use FilamentTablesFiltersTrashedFilter;
use FilamentTablesTable;
use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentSoftDeletingScope;
use IlluminateSupportStr;

class OrganizationResource extends Resource
{
    protected static ?string $model = Organization::class;

    protected static ?string $slug = 'organizations';

    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';

    protected static ?string $tenantOwnershipRelationshipName = 'users';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                TextInput::make('name')
                    ->required()
                    ->reactive(),

                TextInput::make('description')
                    ->required(),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                TextColumn::make('slug')
                    ->searchable()
                    ->sortable(),

                TextColumn::make('description'),

                TextColumn::make('verified_at')
                    ->label('Verified Date')
                    ->date(),
            ])
            ->filters([
                TrashedFilter::make(),
            ])
            ->actions([
                EditAction::make(),
                DeleteAction::make(),
                RestoreAction::make(),
                ForceDeleteAction::make(),
            ])
            ->bulkActions([
                BulkActionGroup::make([
                    DeleteBulkAction::make(),
                    RestoreBulkAction::make(),
                    ForceDeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getPages(): array
    {
        return [
            'index' => PagesListOrganizations::route('/'),
            'create' => PagesCreateOrganization::route('/create'),
            'edit' => PagesEditOrganization::route('/{record}/edit'),
        ];
    }

    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()
            ->withoutGlobalScopes([
                SoftDeletingScope::class,
            ]);
    }

    public static function getGloballySearchableAttributes(): array
    {
        return ['name', 'slug'];
    }
}

And the following Organization Model

<?php

namespace AppModels;

use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsBelongsToMany;
use IlluminateDatabaseEloquentSoftDeletes;
use SpatiePermissionTraitsHasRoles;
use SpatieSluggableHasSlug;
use SpatieSluggableSlugOptions;

class Organization extends Model
{
    use SoftDeletes, HasSlug, HasRoles;

    protected $fillable = [
        'name',
        'slug',
        'description',
        'is_admin',
    ];

    public static function boot()
    {
        parent::boot();

        // here assign this team to a global user with global default role
        self::created(function ($model) {
            // temporary: get session team_id for restore at end
            $session_team_id = config('organizations.default_organization_id');
            // set actual new team_id to package instance
            setPermissionsTeamId($model);
            // restore session team_id to package instance using temporary value stored above
            setPermissionsTeamId($session_team_id);
        });
    }

    protected function casts(): array
    {
        return [
            'verified_at' => 'datetime',
        ];
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }


    public function getSlugOptions(): SlugOptions
    {
        return SlugOptions::create()
            ->generateSlugsFrom('name')
            ->saveSlugsTo('slug')
            ->doNotGenerateSlugsOnUpdate()
            ->usingSeparator('-');
    }
}

And the following AdminPanelProvider

public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('')
            ->login()
            ->tenant(Organization::class, slugAttribute: 'slug')
            ->brandLogo(fn () => view('filament.admin.logo'))
            ->registration(Registration::class)
            ->emailVerification(RegistrationConfirmation::class)
            ->colors([
                'primary' => Color::Amber,
            ])
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
            ->pages([
                PagesDashboard::class,
            ])
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
            ->widgets([
                WidgetsAccountWidget::class,
                WidgetsFilamentInfoWidget::class,
            ])
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
            ])
            ->tenantMiddleware([
                TeamsPermission::class,
                ApplyTenantScopes::class,
            ], isPersistent: true)
            ->plugins([
                FilamentShieldPlugin::make(),
                FilamentLogManager::make(),
                ResourceLockPlugin::make(),
                FilamentSpatieLaravelHealthPlugin::make(),
                ActivitylogPlugin::make()
                    ->navigationGroup('Audit')
                    ->authorize(fn () => auth()->user()->can('super_admin'))
                    ->navigationSort(3),
            ])
            ->authMiddleware([
                Authenticate::class,
            ])
            ->unsavedChangesAlerts()
            ->databaseTransactions();
    }

As you can see I’ve set the protected static ?string $tenantOwnershipRelationshipName = 'users'; to the Organization, but there are no resources being displayed. I’m assuming that it’s not scoping correctly the data but I don’t know how to set it up in order to work.

If I remove the Tenant from this resource and only scope it with the Permissions that I have assigned to the Role thanks to the Spatie package everything is working as intended.

Could someone please explain how am I suppose to setup this in order to work. I have problems with lots of other models that are having Tenancy with deep nested relations.

For example Media -> Game -> User -> Organizations.

Thanks in advance.

2

Answers


  1. Chosen as BEST ANSWER

    The Filament docs says it clear (not so clear for me at first :D).

    When creating and listing records associated with a Tenant, Filament needs access to two Eloquent relationships for each resource - an "ownership" relationship that is defined on the resource model class, and a relationship on the tenant model class. By default, Filament will attempt to guess the names of these relationships based on standard Laravel conventions. For example, if the tenant model is AppModelsTeam, it will look for a team() relationship on the resource model class. And if the resource model class is AppModelsPost, it will look for a posts() relationship on the tenant model class.

    Every model that you want to be tenant aware, it has to contain a team_id, organization_id or what ever you choose_id in order for this to work.

    This is needed so that the data can be scoped and filtered out for each tenant.


  2. Filament uses a "tenant-aware" approach to make sure each tenant (in your case, an organization) only sees and manages their own records. To achieve this, Filament expects two relationships to be defined:

    An "ownership" relationship on the resource model (e.g., users on Organization).
    This tells Filament who owns this record.
    A relationship on the tenant model (e.g., organizations on User).
    This links the tenant to the resource.
    For Filament to work smoothly, these relationships must exist and must be set up correctly in both directions.

    Steps to Fix Your Problem

    1. Check the Ownership Relationship on the Organization Model
      In your Organization model, you already have this relationship:

      public function users(): BelongsToMany
      {
      return $this->belongsToMany(User::class);
      }

    This is correct, and Filament should use it as the "ownership" relationship.

    1. Check the Tenant Relationship on the User Model
      Your User model should define a corresponding relationship back to the Organization. For example:

      public function organizations(): BelongsToMany
      {
      return $this->belongsToMany(Organization::class);
      }

    This relationship allows Filament to understand which organizations a user belongs to.

    1. Add Scoping Logic to getEloquentQuery
      You need to explicitly scope the OrganizationResource to show only the records related to the current tenant. Here’s how to do it:

      public static function getEloquentQuery(): Builder
      {
      // If the user belongs to an admin organization, show all organizations
      $isAdmin = auth()->user()?->organizations()->where(‘is_admin’, true)->exists();

       if ($isAdmin) {
           return parent::getEloquentQuery();
       }
      
       // Otherwise, scope to organizations the user is part of
       return parent::getEloquentQuery()
           ->whereHas('users', function ($query) {
               $query->where('users.id', auth()->id());
           });
      

      }

    This ensures that regular users only see the organizations they belong to, while admins see everything.

    1. Tenant Field in Other Models
      For Filament to handle tenant-aware records like Media, Game, or others, they need a field that identifies their tenant (e.g., organization_id). Without this, Filament has no way of knowing which tenant the record belongs to.

    Example:

    Add an organization_id column to models like Media or Game.
    Define relationships back to Organization:

    public function organization(): BelongsTo
    {
        return $this->belongsTo(Organization::class);
    }
    

    Then, scope their queries accordingly, similar to what we did in OrganizationResource.

    1. Set the Tenant in the Panel
      Your AdminPanelProvider is almost correct. Ensure you’ve defined the tenant method to use the Organization model. This helps Filament apply the tenant scope consistently:

      ->tenant(Organization::class, slugAttribute: ‘slug’)

    This line ensures Filament knows how to resolve the active tenant.

    1. Testing
      To verify that everything works:

    Test as a regular user: Log in as a user belonging to a single organization. Check if only their organization is visible.
    Test as an admin user: Log in as a user with access to the admin organization. Check if they see all organizations.

    Why Your Current Setup Isn’t Working?
    -The missing piece is that Filament doesn’t know how to scope data because:

    1.The relationships between User and Organization are not clearly defined for Filament.
    2.Tenant scoping isn’t explicitly applied in getEloquentQuery.

    Once these are corrected, Filament will automatically filter data based on the current tenant.

    Every tenant-aware model must:

    1.Have a tenant field (organization_id, team_id, etc.).
    2.Define relationships that link the tenant and resource model.
    3.Use getEloquentQuery to scope data when necessary.

    I hope this clarifies everything! Let me know if you need more guidance. 😊

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search