phplaravelmulti-tenantlaravel-filament

Filament Multi-tenancy extended with Spatie Permissions


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 App\Filament\Resources;

use App\Filament\Resources\OrganizationResource\Pages;
use App\Models\Organization;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ForceDeleteAction;
use Filament\Tables\Actions\ForceDeleteBulkAction;
use Filament\Tables\Actions\RestoreAction;
use Filament\Tables\Actions\RestoreBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Str;

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' => Pages\ListOrganizations::route('/'),
            'create' => Pages\CreateOrganization::route('/create'),
            'edit' => Pages\EditOrganization::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 App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;

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([
                Pages\Dashboard::class,
            ])
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
            ->widgets([
                Widgets\AccountWidget::class,
                Widgets\FilamentInfoWidget::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.


Solution

  • 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 App\Models\Team, it will look for a team() relationship on the resource model class. And if the resource model class is App\Models\Post, 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.