A client's support team could log into the admin panel, open Customers, and read every record in the database. The CustomerPolicy was there. update() and delete() returned false for support agents, exactly as intended. But the list page still returned all 40,000 rows, because a policy authorises an action on a record — it does not decide which records go into the query.
That gap is the whole problem with Filament v5 authorization. The framework gives you four separate layers, the docs describe each one in isolation, and a panel is only as locked down as the layer you forgot. Here is all four, wired into one working Customer resource, with Pest tests that fail loudly when someone regresses a policy.
Gate panel access with the FilamentUser contract#
Start at the front door. By default every App\Models\User can access a Filament panel in a local environment, and unless you implement the FilamentUser contract, that stays true in production too. This is the single most common way a panel gets exposed: authentication works, nobody added authorization, and every registered user has an admin login.
<?php
namespace App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements FilamentUser
{
// ...
public function canAccessPanel(Panel $panel): bool
{
return match ($panel->getId()) {
'admin' => $this->hasVerifiedEmail() && $this->is_staff,
'customer' => true,
default => false,
};
}
}
The $panel argument matters more than it looks. If your app has an admin panel and a customer-facing panel, a bare return $this->is_staff locks customers out of their own portal. Match on $panel->getId() and be explicit about every panel, with a default that denies — that way adding a third panel later fails closed rather than open.
Two properties of this check are worth internalising. First, canAccessPanel() is enforced by the panel's Authenticate middleware, which runs on every HTTP request including Livewire updates. A user who loses panel access mid-session is bounced at the middleware layer on their next interaction, before any component-level authorization is consulted. Second, because it is middleware, it runs before any resource policy. If canAccessPanel() returns false, none of the rest of this article ever executes. That makes it the right place for coarse, panel-wide rules and the wrong place for anything per-model.
If you are still standing up the panel itself, the zero-to-production Filament dashboard walkthrough covers install and resource scaffolding. And if you are on the previous major, the Filament v4 to v5 upgrade is a short trip — v5's only substantive change is Livewire 4 support, so everything below applies to v4 panels almost unchanged.
Generate model policies for your resources#
With the front door closed, authorization inside the panel is pure Laravel. Filament observes any model policy registered in your app and calls it before rendering pages, navigation items and actions. Generate one per resource model, and populate it — an empty generated policy denies everything, which produces the single most-reported Filament bug that is not a bug.
php artisan make:policy CustomerPolicy --model=Customer
<?php
namespace App\Policies;
use App\Models\Customer;
use App\Models\User;
class CustomerPolicy
{
public function viewAny(User $user): bool
{
return $user->can('customer.view');
}
public function view(User $user, Customer $customer): bool
{
return $user->can('customer.view')
&& ($user->can('customer.view.all') || $customer->owner_id === $user->id);
}
public function create(User $user): bool
{
return $user->can('customer.create');
}
public function update(User $user, Customer $customer): bool
{
return $user->can('customer.update') && $customer->owner_id === $user->id;
}
public function delete(User $user, Customer $customer): bool
{
return $user->can('customer.delete') && $customer->owner_id === $user->id;
}
public function deleteAny(User $user): bool
{
return $user->can('customer.delete');
}
public function restore(User $user, Customer $customer): bool
{
return $user->can('customer.delete');
}
public function restoreAny(User $user): bool
{
return $user->can('customer.delete');
}
public function forceDelete(User $user, Customer $customer): bool
{
return $user->hasRole('admin');
}
public function forceDeleteAny(User $user): bool
{
return $user->hasRole('admin');
}
public function reorder(User $user): bool
{
return $user->can('customer.update');
}
}
Since Laravel 11, policies in app/Policies matching the {Model}Policy convention are auto-discovered, so there is no AuthServiceProvider registration step. If your models live outside App\Models, register the mapping with Gate::policy() in a service provider or the discovery will silently miss them — and a missing policy means Filament falls back to allowing the action, not denying it.
Note the split between delete() and deleteAny(). That is not redundancy, and the next section explains why it exists.
Map resource actions onto policy methods#
Every user-visible affordance in a Filament resource resolves to a named policy method, and knowing the map is what turns authorization from guesswork into a checklist. Filament v5 calls these methods:
viewAny()— navigation item visibility, and access to every page in the resource.view()— the View page andViewAction.create()— the Create page andCreateAction.update()— the Edit page andEditAction.delete()/deleteAny()—DeleteAction/DeleteBulkAction.restore()/restoreAny()—RestoreAction/RestoreBulkAction.forceDelete()/forceDeleteAny()—ForceDeleteAction/ForceDeleteBulkAction.reorder()— drag-and-drop reordering in the table.
The *Any variants exist for performance. When a user selects 500 rows and hits Delete, Filament calls deleteAny() once rather than iterating 500 records through delete(). That is the right default, but it means a per-record rule like $customer->owner_id === $user->id in delete() is simply not consulted during a bulk delete. If per-row checks matter — and for ownership rules they usually do — opt in explicitly:
use Filament\Actions\DeleteBulkAction;
DeleteBulkAction::make()
->authorizeIndividualRecords()
Records that fail the check are skipped rather than the whole batch failing. If you are building custom bulk operations, the same principle applies to them; the import and export bulk actions guide covers the surrounding plumbing.
Filament v5 also re-runs these checks on every Livewire request, not just the initial mount. Searching, filtering, paginating and calling an action each re-authorize against the current policy state via the CanAuthorizeResourceAccess trait. Revoke a permission while someone has the page open and their next keystroke in the search box is denied. That is a genuine improvement over older versions, where a mounted component kept its mount-time verdict.
There is an escape hatch — protected static bool $shouldSkipAuthorization = true; on the resource — and I would treat reaching for it as a design smell. It disables the entire policy layer for that resource, including navigation visibility, and there is no way to re-enable it for one page.
Scope the resource query to records the user owns#
This is the layer that bites, and the one my client's panel was missing. view() returning false for a record does not remove that record from the list page — Filament's resource query returns everything by default, and the policy is only consulted when the user tries to open or act on a row. Names, emails and any other column you put in the table are already rendered.
Override getEloquentQuery() on the resource. It is the base of every query the resource makes, on every page, so scoping it once covers the list table, global search, ViewAction record resolution and route-model binding together.
<?php
namespace App\Filament\Resources\Customers;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Builder;
class CustomerResource extends Resource
{
protected static ?string $model = Customer::class;
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
if (auth()->user()->can('customer.view.all')) {
return $query;
}
return $query->where('owner_id', auth()->id());
}
}
Two rules I hold to here. Call parent::getEloquentQuery() rather than Customer::query() — the parent applies soft-delete handling and the resource's own configuration, and rebuilding it by hand quietly drops both. And make the unscoped path the explicit exception, as above, rather than writing if (! $user->isAdmin()) { $query->where(...) }: when a new role appears that nobody thought about, you want it to see nothing rather than everything.
For a table-only constraint that should not affect the Edit or View pages, use modifyQueryUsing() on the table instead. The distinction is real: getEloquentQuery() is a security boundary, modifyQueryUsing() is a presentation filter. Do not use the second where you need the first — the status tabs and filters on a v5 list page are exactly the presentational case.
If your scoping rule is "this team's records", stop hand-rolling it. Filament's first-party panel tenancy scopes every resource automatically, and the team-based multi-tenancy setup does it in one call on the panel provider. Custom pages, custom actions and relation-manager queries still need manual scoping even under tenancy.
Restrict row actions and bulk actions#
Built-in actions in a resource — EditAction, DeleteAction, ViewAction — read the model policy automatically, so a user whose update() returns false simply never sees the Edit button. You do not wire those up. Custom actions are the opposite: Filament cannot infer intent, so an unauthorized custom action is fully clickable until you say otherwise.
use App\Jobs\SendMonthlyStatement;
use App\Models\Customer;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Table;
public static function configure(Table $table): Table
{
return $table
->recordActions([
EditAction::make(),
Action::make('send-statement')
->icon('heroicon-o-envelope')
->requiresConfirmation()
->authorize('sendStatement')
->action(fn (Customer $record) => SendMonthlyStatement::dispatch($record)),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorizeIndividualRecords(),
]),
]);
}
authorize('impersonate') passes the method name to the policy for the action's model, which keeps the rule in the policy where the rest of your authorization lives. When you need something that is not a policy method, visible() and hidden() take a closure:
Action::make('resend-welcome')
->visible(fn (Customer $record): bool => auth()->user()->can('customer.update')
&& $record->email_verified_at === null)
By default a denied action is hidden entirely, which is usually right — but silently missing buttons generate support tickets. If your policy returns a Laravel response message rather than a bare false, you can keep the action visible and explain the refusal:
Action::make('archive')
->authorize('archive')
->authorizationTooltip()
authorizationTooltip() disables the button and surfaces the policy's message on hover. authorizationNotification() leaves it clickable and fires the message as a notification instead. Both fall back to hiding the action if the denial carries no message — a plain false, or a Gate::before() hook short-circuiting the check — so add authorizationMessage() as a fallback if you need it visible regardless. For the surrounding UX of confirmation modals and notifications, see custom action confirmations and notifications.
Relation managers deserve a specific mention: they authorize through canViewForRecord(), re-checked on every Livewire request, and their table queries are not covered by the parent resource's getEloquentQuery(). Scope them separately — the many-to-many relation manager guide shows where that query lives.
Hide or disable fields and columns by permission#
Authorization at page granularity produces a bad panel. A support agent who may edit a customer's contact details but not their credit limit should get a form with one disabled field, not a 403. Fields and columns take the same closures as actions.
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required(),
TextInput::make('credit_limit')
->numeric()
->prefix('£')
->disabled(fn (): bool => ! auth()->user()->can('customer.manage-credit'))
->dehydrated(fn (): bool => auth()->user()->can('customer.manage-credit')),
Select::make('owner_id')
->relationship('owner', 'name')
->visible(fn (): bool => auth()->user()->can('customer.reassign')),
]);
}
dehydrated(false) is the part people miss. disabled() stops the browser rendering an editable input, but the field is still part of the form state, and a tampered Livewire payload can carry a value for it. Pairing disabled() with a matching dehydrated() closure means the value is excluded from the data that gets saved, so the field is protected on the server rather than in the DOM. This is the same class of problem that locked properties solve in Livewire 4, and it is worth reading if you write custom Livewire components inside the panel.
Table columns use visible() the same way. But there is one genuinely dangerous case, and it is documented in Filament's own security notes rather than anywhere obvious:
use Filament\Tables\Columns\ToggleColumn;
ToggleColumn::make('is_active')
->disabled(fn (Customer $record): bool => ! auth()->user()->can('update', $record))
Inline editable columns — ToggleColumn, TextInputColumn, SelectColumn, CheckboxColumn — do not check model policies before saving, as Filament's security notes spell out. They check the column's disabled() state and nothing else. If a user can see a row and the column is not disabled, they can write to that column regardless of what your update() policy says. This is the sharpest edge in the whole framework, because everything around it authorizes automatically and these four components silently do not. Gate every inline editable column by hand, or use a modal action instead where resource authorization is enforced.
For a read-only role, an infolist is a cleaner answer than a form full of disabled inputs; the read-only record view with infolists covers that shape.
Control page, widget and navigation visibility#
Resources are not the only thing in a panel. Custom pages, widgets and manually registered navigation items each have their own hook, and each one is public static — which trips people up, because most Filament overrides are instance methods and a non-static canAccess() fails silently rather than erroring.
A custom page authorizes itself with canAccess(). This both hides the navigation entry and blocks direct URL access:
namespace App\Filament\Pages;
use Filament\Pages\Page;
class RevenueReport extends Page
{
public static function canAccess(): bool
{
return auth()->user()->can('report.revenue');
}
}
Widgets use canView():
namespace App\Filament\Widgets;
use Filament\Widgets\Widget;
class RevenueChart extends Widget
{
public static function canView(): bool
{
return auth()->user()->can('report.revenue');
}
}
Both re-run on every Livewire request, so a widget polling in the background stops returning data the moment the permission is revoked. If you are assembling dashboards from custom pages and widgets, the custom Filament v5 page hosting Livewire widgets walks through registering them.
Manually registered navigation items take visible() or hidden():
use Filament\Navigation\NavigationItem;
NavigationItem::make('Analytics')
->visible(fn (): bool => auth()->user()->can('view-analytics'))
And the trap: shouldRegisterNavigation() is not an authorization method.
public static function shouldRegisterNavigation(): bool
{
return false;
}
That hides the sidebar link and nothing else. The route still resolves, and anyone who knows or guesses the URL walks straight in. Filament's docs flag this explicitly. Use it for decluttering; use canAccess(), canView() or viewAny() for security. Any time you find yourself hiding something from navigation for a security reason, you have picked the wrong method.
Layer roles on top with spatie/laravel-permission#
Everything above has been calling $user->can('customer.update') without saying where those abilities come from. Hard-coded role checks ($user->role === 'admin') rot fast — the day someone needs a manager who can delete but not force-delete, you are editing policy classes. spatie/laravel-permission v8 stores roles and permissions in the database and, crucially, feeds them into the same can() calls your policies already make.
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan optimize:clear
php artisan migrate
Add the trait to the User model:
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser
{
use HasRoles;
// ...
}
The package registers a Gate::before() hook that resolves permissions from the database for every can() call. That is the join between the two systems, and it is why the policies above needed no modification: $user->can('customer.update') now consults the database rather than a hard-coded list. Permissions are inherited from roles automatically, so you assign roles to users and permissions to roles.
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
foreach ([
'customer.view',
'customer.view.all',
'customer.create',
'customer.update',
'customer.delete',
'customer.reassign',
'customer.manage-credit',
'report.revenue',
] as $permission) {
Permission::findOrCreate($permission);
}
Role::findOrCreate('support')
->syncPermissions(['customer.view', 'customer.update']);
Role::findOrCreate('manager')
->syncPermissions([
'customer.view',
'customer.view.all',
'customer.create',
'customer.update',
'customer.delete',
'customer.manage-credit',
'report.revenue',
]);
Role::findOrCreate('admin')
->syncPermissions(Permission::all());
}
}
The forgetCachedPermissions() call at the top is not optional — the package caches the permission table aggressively, and a seeder that skips it produces a test suite that passes locally and fails in CI for reasons nobody can reproduce.
For a super-admin, use a global gate rather than a role with every permission attached:
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::before(fn ($user, $ability) => $user->hasRole('super-admin') ? true : null);
}
Return null, never false. Returning false short-circuits every other policy in the application. And be aware the super-admin bypass only applies to gate-routed checks — can() and canAny() respect it, while direct calls to hasPermissionTo() or hasAllPermissions() do not. Use can() in policies and this stays consistent.
On Filament Shield: it generates spatie permissions named after your resources and gives you a UI for managing them. That is genuinely useful when non-developers administer roles. But it generates policies for you, and if you do not understand the four layers above, you will end up with a panel that has permissions for everything and query scoping for nothing — which is precisely the failure this article opened with. Shield is a good accelerator on top of a model you understand, and a poor substitute for understanding it. If you are already comfortable managing another Spatie package inside a panel, the Filament settings page with spatie/laravel-settings shows the same integration shape.
Cover the rules with Pest tests#
Authorization is the part of an application where a regression is silent — nothing errors, data just becomes visible to the wrong people. Write the tests. Filament ships Livewire testing helpers that make them short, and a handful of assertions covers the layers that actually leak.
<?php
use App\Filament\Resources\Customers\CustomerResource;
use App\Filament\Resources\Customers\Pages\EditCustomer;
use App\Filament\Resources\Customers\Pages\ListCustomers;
use App\Models\Customer;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Filament\Actions\Testing\TestAction;
use Filament\Facades\Filament;
use function Pest\Laravel\actingAs;
use function Pest\Livewire\livewire;
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
$this->support = User::factory()->create(['is_staff' => true])->assignRole('support');
$this->manager = User::factory()->create(['is_staff' => true])->assignRole('manager');
});
it('denies panel access to a user without a staff flag', function () {
$outsider = User::factory()->create(['is_staff' => false]);
expect($outsider->canAccessPanel(Filament::getPanel('admin')))->toBeFalse();
});
it('only lists customers the support agent owns', function () {
$own = Customer::factory()->count(2)->create(['owner_id' => $this->support->id]);
$other = Customer::factory()->count(3)->create();
actingAs($this->support);
livewire(ListCustomers::class)
->assertOk()
->assertCanSeeTableRecords($own)
->assertCanNotSeeTableRecords($other)
->assertCountTableRecords(2);
});
it('hides the delete action from a support agent', function () {
$customer = Customer::factory()->create(['owner_id' => $this->support->id]);
actingAs($this->support);
livewire(ListCustomers::class)
->assertActionHidden(TestAction::make('delete')->table($customer))
->assertActionVisible(TestAction::make('edit')->table($customer));
});
it('forbids the create page for a support agent but allows a manager', function () {
actingAs($this->support);
$this->get(CustomerResource::getUrl('create'))->assertForbidden();
actingAs($this->manager);
$this->get(CustomerResource::getUrl('create'))->assertOk();
});
it('does not persist a credit limit edited by an unauthorised user', function () {
$customer = Customer::factory()->create([
'owner_id' => $this->support->id,
'credit_limit' => 500,
]);
actingAs($this->support);
livewire(EditCustomer::class, ['record' => $customer->getKey()])
->fillForm(['credit_limit' => 999_999])
->call('save');
expect($customer->refresh()->credit_limit)->toBe(500);
});
Note the shape of the last one. It does not assert that the field is disabled — it asserts that a tampered value never reaches the database. Disabled-in-the-DOM is a UX property; not-persisted is the security property, and that is the one worth a test. Table actions in v5 are targeted with TestAction::make('name')->table($record) rather than the old assertTableActionHidden() helpers, which is the main thing that changes if you are porting tests from v3.
Run the suite in CI on every pull request so a policy regression fails the build rather than reaching production. If you want more on the Livewire side of these assertions, the Pest testing guide for Livewire 4 components goes deeper on livewire(), form filling and state assertions — worth reading before you write custom panel components, because those get none of Filament's automatic authorization and every rule in them is yours to enforce and yours to test.
FAQ#
How does authorization work in Filament v5?
Filament v5 does not implement its own authorization system — it delegates to Laravel. The FilamentUser contract's canAccessPanel() method gates entry to the panel at the middleware layer, and standard Laravel model policies gate everything inside it. Filament calls policy methods like viewAny(), create(), update() and delete() before rendering pages, navigation items and actions, and re-runs those checks on every Livewire request rather than only on initial mount.
How do I restrict access to a Filament panel?
Implement Filament\Models\Contracts\FilamentUser on your User model and define canAccessPanel(Panel $panel): bool. Return true only for users who should reach that panel, and match on $panel->getId() if your app has more than one panel so you do not accidentally lock users out of a customer portal while securing the admin panel. Without this contract, every authenticated user can access the panel in a local environment, so treat implementing it as a required step rather than an optional hardening measure.
Do I need Filament Shield for roles and permissions?
No. Shield is a convenience layer that generates spatie/laravel-permission records named after your resources and gives you a UI for managing them, but Filament's authorization works fine with hand-written policies and spatie roles alone. Shield is worth adding when non-developers need to administer roles through the panel. It is not worth adding as a way of avoiding an understanding of policies, because it generates policy files you will still need to read when something is denied unexpectedly.
How do I hide a Filament resource from certain users?
Return false from the model policy's viewAny() method. That single method hides the resource's navigation item and blocks access to every page within the resource, including direct URL access. Do not use shouldRegisterNavigation() for this — it only removes the sidebar link while leaving the routes fully reachable to anyone who types the URL.
How do I hide a single form field based on permissions in Filament?
Chain visible() or disabled() onto the field with a closure that checks the permission, for example ->disabled(fn (): bool => ! auth()->user()->can('customer.manage-credit')). Pair disabled() with a matching dehydrated() closure so the value is also excluded from the saved data — disabled() alone only stops the browser rendering an editable input, and a tampered Livewire payload can still carry a value for the field.
How do I stop users seeing other users' records in Filament?
Override the static getEloquentQuery() method on the resource and add a where() constraint scoping the query to the current user. Model policies authorise actions on individual records but do not filter the list query, so without query scoping a table renders every row in the table regardless of what view() returns. Because getEloquentQuery() is the base of every query the resource makes, scoping it once covers the list page, global search and route-model binding together.
How do I test Filament authorization with Pest?
Authenticate as a user with the role under test using actingAs(), then drive the resource page as a Livewire component with livewire(ListCustomers::class). Assert list scoping with assertCanSeeTableRecords() and assertCanNotSeeTableRecords(), action visibility with assertActionHidden(TestAction::make('delete')->table($record)), and page access with a plain HTTP call such as $this->get(CustomerResource::getUrl('create'))->assertForbidden(). The most valuable assertion is the one that fills a disabled field and checks the value never reached the database.
Why is my Filament resource missing from the navigation menu?
Almost always because a model policy exists and its viewAny() method returns false. Laravel generates policy methods with empty bodies, and an empty method returns null, which the gate treats as a denial. Filament uses viewAny() to decide whether the navigation item renders at all, so a freshly generated policy hides the resource completely until you populate that method.
Does Filament re-check authorization on every Livewire request?
Yes. Filament v5 re-runs resource, page, relation manager and widget authorization on every Livewire update — search, filter, pagination, action call and form interaction — not only on initial mount. Be aware, though, that Livewire deserialises public properties and runs boot() before Filament's authorization hooks fire, so any side effects you place in those early hooks will execute even when the request is subsequently aborted. Put anything sensitive in the mount() body after an explicit authorizeAccess() call, or in an action method.