agroezinger / filament-shield-enhanced
Fine-grained page permissions & structured UI addon for bezhansalleh/filament-shield.
Package info
github.com/agroezinger/filament-shield-enhanced
pkg:composer/agroezinger/filament-shield-enhanced
Requires
- php: ^8.2
- bezhansalleh/filament-shield: ^4.2|^4.3
- filament/filament: ^4.0|^5.0
- illuminate/contracts: ^11.28|^12.0|^13.0
- illuminate/support: ^11.28|^12.0|^13.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- laravel/pint: ^1.26
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.8|^11.0
- pestphp/pest: ^3.8|^4.0
- pestphp/pest-plugin-laravel: ^3.2|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Warning
Testing Phase: Versions 0.*.* are currently in the testing phase. At present, there are no known bugs.
A standalone addon for bezhansalleh/filament-shield that adds fine-grained page, resource and component permissions and a structured Role Resource UI — without forking or replacing the original package.
Why this exists.
The features were proposed upstream in bezhanSalleh/filament-shield#698. The author has not had time to review the PR. This addon ships the same functionality as a composable layer on top of the official package.
Table of Contents
- Features
- Requirements
- Installation
- Usage — Pages
- Usage — Resources
- Usage — Components
- Configuration
- Localization
- How it works internally
- Changelog
- License
- Credits
Features
| Feature | Description |
|---|---|
| Multi-action page permissions | Declare several permissions per page via getShieldPagePermissions(). |
| Multi-action resource permissions | Declare custom permissions per resource via getShieldResourcePermissions() — beyond the standard CRUD policy methods. |
| Multi-action component permissions | Declare permissions on any Livewire component (not registered with a panel) via getShieldComponentPermissions(). |
canShield('action') |
Fluent, type-safe permission check — instance method on Pages and Components, static method on Resources. |
getShieldPermissions() |
Returns a pre-resolved action → bool map for injection into child Livewire components. |
HasInjectedShieldPermissions |
Trait for child Livewire components that receive the map from a parent page or component. |
EnhancedPagePermissionsForm |
Form builder helper for the published RoleResource — one Section per Page, combining filament-shield's own standard permission with any fine-grained actions from getShieldPagePermissions() in the same checkbox list. |
EnhancedResourcePermissionsForm |
Form builder helper for the published RoleResource — one Section per Resource, combining the standard CRUD permissions with any fine-grained actions from getShieldResourcePermissions() in the same checkbox list. |
EnhancedComponentPermissionsForm |
Form builder helper for the published RoleResource — renders each enhanced component as a separate Section with individual checkboxes. |
getShieldPermissionDescriptions() |
Optional hook on Resources/Pages (with or without fine-grained actions) to attach a help text under any individual permission checkbox — standard CRUD included. Use it where the checkbox's real-world effect deviates from what its label implies. |
NavigationGroupResolver |
Resolves a Resource's/Page's navigation group to a display string, and the panel's own ->navigationGroups() order — the building block behind grouping the RoleResource UI the same way the sidebar is grouped. |
discoverResources() / discoverPages() |
Public on EnhancedResourcePermissionsForm / EnhancedPagePermissionsForm — return every Resource/Page's merged permission options, descriptions, navigation group and sort as plain data, for building a custom RoleResource layout (see §8c). |
| Three-part page key convention | {Prefix}{sep}{Action}{sep}{Subject} (e.g. Page:EditSettings:SettingsPage) — fully respects filament-shield's separator and case config. |
| Three-part component key convention | {Prefix}{sep}{Action}{sep}{Subject} (e.g. Component:Delete:CommentComponent) — same shape as pages, configurable prefix. |
| Two-part resource key convention | {Action}{sep}{ModelBasename} (e.g. ViewContactInfo:Member) — matches Shield's own resource permission format, no extra prefix. |
| Zero conflict | Does not replace any original class. Falls back gracefully on entities that do not declare the method. |
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^11.0 | ^12.0 | ^13.0 |
| Filament | ^4.0 | ^5.0 |
| bezhansalleh/filament-shield | ^4.0 |
Installation
composer require agroezinger/filament-shield-enhanced
Publish the config (optional):
php artisan vendor:publish --tag="filament-shield-enhanced-config"
Usage — Pages
1 — Declare fine-grained permissions on a Page
Replace (or complement) the original HasPageShield with the enhanced version:
<?php namespace App\Filament\Pages; use Agroezinger\FilamentShieldEnhanced\Traits\HasPageShield; use Filament\Pages\Page; class SettingsPage extends Page { use HasPageShield; /** * Declare every action that can be independently granted on this page. * The 'view' action controls whether the user can navigate to the page at all. * * Three entry formats can be mixed freely: * * 'action' → label auto-generated from action name * 'action' => 'Label' → explicit label * 'action' => ['text' => 'Label', * 'description' => 'Shown below the checkbox in the role editor'] */ public static function getShieldPagePermissions(): array { return [ 'view' => 'Can view this page', 'editGlobalSettings' => [ 'text' => 'Can change global settings', 'description' => 'Grants access to all fields in the Global Settings section.', ], 'exportData' => 'Can export data as CSV / Excel', ]; } }
Then run the enhanced generator to create the permissions in the database:
php artisan shield:generate-enhanced-pages --all-panels
Use
--panel=<id>to limit the scan to a single panel.
This will create three permissions for the page above:
Page:View:SettingsPage
Page:EditGlobalSettings:SettingsPage
Page:ExportData:SettingsPage
2 — Check permissions in PHP (Pages)
// Inside the Page class if ($this->canShield('editGlobalSettings')) { // Perform restricted action }
{{-- Inside the Page Blade view --}} @if($this->canShield('exportData')) <x-filament::button wire:click="export">Export</x-filament::button> @endif
3 — Inject permissions into child Livewire components
Parent page Blade:
@livewire('settings-sidebar', [ 'permissions' => $this->getShieldPermissions() ])
Child Livewire component:
<?php namespace App\Livewire; use Agroezinger\FilamentShieldEnhanced\Traits\HasInjectedShieldPermissions; use Livewire\Component; class SettingsSidebar extends Component { use HasInjectedShieldPermissions; // $this->permissions is automatically populated by Livewire. public function save(): void { $this->authorizeShield('editGlobalSettings'); // aborts 403 if not permitted // … save logic } public function render() { return view('livewire.settings-sidebar'); } }
Usage — Resources
4 — Declare fine-grained permissions on a Resource
Add HasResourceShield to any Filament Resource and declare custom actions via getShieldResourcePermissions():
<?php namespace App\Filament\Resources; use Agroezinger\FilamentShieldEnhanced\Traits\HasResourceShield; use App\Models\Member; use Filament\Resources\Resource; class MemberResource extends Resource { use HasResourceShield; protected static ?string $model = Member::class; /** * Declare custom permissions beyond the standard CRUD policy methods. * Keys are action names; values are human-readable labels (shown in the role editor). * * Same three entry formats as getShieldPagePermissions(): * 'action' → auto-generated label * 'action' => 'Label' → explicit label * 'action' => ['text' => '...', 'description' => '...'] */ public static function getShieldResourcePermissions(): array { return [ 'Export' => 'Export member list (basic data)', 'ExportFinance' => 'Export member list including financial data (IBAN, fees)', 'ViewContactInfo' => 'View contact details (email, phone, address)', 'ViewBankingInfo' => 'View bank details (IBAN, BIC, account holder)', ]; } }
Then create the permissions in the database:
php artisan shield:generate-enhanced-resources --all-panels
This will create (for the example above):
Export:Member
ExportFinance:Member
ViewContactInfo:Member
ViewBankingInfo:Member
The key format (Action:ModelBasename) is identical to Shield's own resource permission format so everything looks consistent.
5 — Check resource permissions in PHP
canShield() is a static method on Resources (unlike Pages, where it is an instance method):
// Anywhere in your application if (MemberResource::canShield('ViewContactInfo')) { // show contact section } // Returns ['Export' => true, 'ViewContactInfo' => false, …] $permissions = MemberResource::getShieldPermissions();
Super-admin bypass is applied automatically — identical behaviour to the page trait.
Attaching a deviation hint to any permission checkbox
getShieldPermissionDescriptions() is a separate, optional hook — it works even on standard CRUD permissions that were never declared via getShieldResourcePermissions()/getShieldPagePermissions(). Use it where the checkbox's real-world effect doesn't match what its label implies (an unimplemented scope, a permission that also grants an unrelated side effect, …):
class SquadResource extends Resource { // No HasResourceShield/getShieldResourcePermissions() needed — this hook // works standalone against filament-shield's own standard CRUD keys too. public static function getShieldPermissionDescriptions(): array { $hint = 'Applies to ALL squads — team-manager assignment and department ' . 'scoping are not enforced here yet.'; return [ 'View:Squad' => $hint, 'Update:Squad' => $hint, 'Delete:Squad' => $hint, ]; } }
The description renders directly under the matching checkbox in the RoleResource UI, regardless of whether that checkbox came from Shield's own CRUD policy methods or from getShieldResourcePermissions()/getShieldPagePermissions().
Usage — Components
Components are arbitrary Livewire components that aren't registered with any Filament panel (e.g. a shared widget dropped into several pages via @livewire(...)). Shield's own Page/Resource/Widget discovery never sees them, so they get their own trait, key format and generator command — everything else (checks, injection, RoleResource UI) works the same way as Pages.
6 — Declare fine-grained permissions on a component
<?php namespace App\Livewire; use Agroezinger\FilamentShieldEnhanced\Traits\HasComponentShield; use Livewire\Component; class CommentComponent extends Component { use HasComponentShield; /** * No default action — unlike pages there is no universally meaningful * "view" action for an arbitrary component, so declare exactly what you need. * Same three entry formats as getShieldPagePermissions(). */ public static function getShieldComponentPermissions(): array { return [ 'delete' => 'Can delete any comment', 'edit' => 'Can edit any comment', ]; } public function delete(int $commentId): void { $this->authorizeShield('delete'); // aborts 403 if not permitted // … } }
By default, components are discovered by scanning app/Livewire for classes using HasComponentShield (configurable — see Configuration). Then create the permissions in the database:
php artisan shield:generate-enhanced-components
This will create (for the example above):
Component:Delete:CommentComponent
Component:Edit:CommentComponent
7 — Check permissions in PHP (Components)
// Inside the component class if ($this->canShield('delete')) { // Show the delete button }
getShieldPermissions() and HasInjectedShieldPermissions work exactly as documented for Pages (see step 3) — a component can inject its resolved permission map into a child component the same way a page does.
8 — Structured UI in the published RoleResource
After publishing the RoleResource with php artisan shield:publish --panel=<id> two files need small changes.
8a — RoleResource: replace the standard Resources/Pages tabs
EnhancedResourcePermissionsForm::make() / EnhancedPagePermissionsForm::make() fully replace Shield's own "Resources"/"Pages" tabs — each Resource/Page gets one Section combining the standard CRUD permissions with any fine-grained actions in the same checkbox list, instead of splitting them across a standard tab and a separate "(Fine-grained)" tab. No getPageOptions() override is needed any more — there is nothing left to de-duplicate.
use Agroezinger\FilamentShieldEnhanced\Forms\EnhancedComponentPermissionsForm; use Agroezinger\FilamentShieldEnhanced\Forms\EnhancedPagePermissionsForm; use Agroezinger\FilamentShieldEnhanced\Forms\EnhancedResourcePermissionsForm; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; public static function getShieldFormComponents(): \Filament\Schemas\Components\Component { $resourceComponents = EnhancedResourcePermissionsForm::make(); $resourceCount = array_sum(array_map('count', EnhancedResourcePermissionsForm::getResourcePermissionFields())); $pageComponents = EnhancedPagePermissionsForm::make(); $pageCount = array_sum(array_map('count', EnhancedPagePermissionsForm::getPagePermissionFields())); $componentComponents = EnhancedComponentPermissionsForm::make(); $componentCount = array_sum(array_map('count', EnhancedComponentPermissionsForm::getComponentPermissionFields())); $tabs = [ static::getTabFormComponentForWidget(), static::getTabFormComponentForCustomPermissions(), ]; if (! empty($resourceComponents)) { $tabs[] = Tab::make('resources') ->label('Resources') ->badge($resourceCount ?: null) ->schema($resourceComponents); } if (! empty($pageComponents)) { $tabs[] = Tab::make('pages') ->label('Pages') ->badge($pageCount ?: null) ->schema($pageComponents); } if (! empty($componentComponents)) { $tabs[] = Tab::make('components') ->label('Components') ->badge($componentCount ?: null) ->schema($componentComponents); } return Tabs::make('Permissions') ->contained() ->tabs($tabs) ->columnSpan('full'); }
Each make() output is already grouped into sub-tabs by navigation group internally, always in the panel's own ->navigationGroups() order — Resources/Pages without a $navigationGroup fall into a "Sonstige" bucket. Widgets and Custom Permissions have no navigation group at all, so they stay on Shield's own standard tabs.
8b — EditRole: add the pre-fill trait
Open the published EditRole.php and add use HasEnhancedRoleForm. This pre-fills page-, resource- and component-permission checkboxes when the form opens.
use Agroezinger\FilamentShieldEnhanced\Traits\HasEnhancedRoleForm; class EditRole extends EditRecord { use HasEnhancedRoleForm; // … rest of the file unchanged }
The mutateFormDataBeforeSave() / afterSave() logic from Shield's own EditRole handles saving — no additional overrides needed.
8c — Optional: group everything by navigation, in one unified tab bar
A plain make() (§8a) puts every Resource/Page in the panel into one flat list of Sections — fine for a handful of them, but once a panel has two or three dozen, a role editor scrolling through an undifferentiated wall of checkboxes stops being able to find anything. Grouping by navigation — the same grouping the sidebar already uses, so nothing new to learn — exists as an option rather than the default specifically to keep that overview intact for larger panels, without forcing it on apps where a flat list is already perfectly readable.
make() (§8a) already groups Resources and Pages by navigation group internally, as two separate top-level tabs ("Resources", "Pages"). If you'd rather have navigation group be the outermost grouping — one tab bar for "Members"/"Team"/"Settings"/…, each containing a "Resources"/"Pages" sub-split underneath, matching the sidebar exactly — combine the public discoverResources() / discoverPages() / buildSection() methods yourself. ui.group_by_navigation (see Configuration) toggles the two:
group_by_navigation: true — top level clusters by navigation group, matching the sidebar exactly (red); a second tab row underneath separates Resources from Pages within each group (green):
group_by_navigation: false — no navigation-group tabs at all; the Resources/Pages/Widgets sub-tabs sit directly at the top level instead, each showing every entry of that type across the whole panel in one flat list (red):
This is exactly the recipe the screenshots above were taken from:
use Agroezinger\FilamentShieldEnhanced\Forms\EnhancedPagePermissionsForm; use Agroezinger\FilamentShieldEnhanced\Forms\EnhancedResourcePermissionsForm; use Agroezinger\FilamentShieldEnhanced\Support\NavigationGroupResolver; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Illuminate\Support\Collection; use Illuminate\Support\Str; public static function getShieldFormComponents(): \Filament\Schemas\Components\Component { $entries = EnhancedResourcePermissionsForm::discoverResources() ->map(fn (array $entry): array => $entry + ['category' => 'resources']) ->concat( EnhancedPagePermissionsForm::discoverPages() ->map(fn (array $entry): array => $entry + ['category' => 'pages']) ); $grouped = $entries->groupBy('navigationGroup'); $ungrouped = $grouped->get('', collect()); // Resources/Pages with no $navigationGroup $groupOrder = NavigationGroupResolver::order(); $groupSort = config('filament-shield-enhanced.ui.group_sort', 'navigation'); $tabs = $grouped->except(['']) ->sortBy(fn (Collection $g, string $label) => $groupSort === 'alphabetical' ? $label : (array_search($label, $groupOrder, true) === false ? count($groupOrder) : array_search($label, $groupOrder, true))) ->map(fn (Collection $g, string $label) => Tab::make(Str::slug($label ?: 'misc')) ->label($label ?: config('filament-shield-enhanced.ui.labels.misc_group', 'Miscellaneous')) ->badge($g->count()) ->schema(static::buildCategorySchema($g))) ->values() ->all(); return Tabs::make('Permissions')->contained()->tabs($tabs)->columnSpan('full'); } /** * Collects the Resources/Pages sub-tabs as plain data (label/badge/schema) * rather than building Tab objects straight away: a Tab can only be * introspected (e.g. to read its schema back out) once it's attached to a * container, which only happens when it's handed to a parent Tabs::make() — * so if only one category ends up present, this reaches for the raw * $categories entry directly instead of building-then-unwrapping a Tab. */ protected static function buildCategorySchema(Collection $entries): array { $labels = [ 'resources' => config('filament-shield-enhanced.ui.labels.resources', 'Management Areas'), 'pages' => config('filament-shield-enhanced.ui.labels.pages', 'Function Pages'), ]; $categories = collect($labels) ->map(function (string $label, string $category) use ($entries): ?array { $categoryEntries = $entries->where('category', $category); if ($categoryEntries->isEmpty()) return null; $sections = $categoryEntries->sortBy('navigationSort')->map( fn (array $entry) => $category === 'resources' ? EnhancedResourcePermissionsForm::buildSection($entry) : EnhancedPagePermissionsForm::buildSection($entry) )->all(); return ['label' => $label, 'badge' => $categoryEntries->count(), 'schema' => [Grid::make()->schema($sections)]]; }) ->filter() ->values(); if ($categories->count() > 1) { return [Tabs::make('categories')->tabs( $categories->map(fn (array $c, int $i) => Tab::make('cat_' . $i)->label($c['label'])->badge($c['badge'])->schema($c['schema']))->all() )]; } return $categories->first()['schema'] ?? []; }
This full pattern — including the "Sonstige" catch-all tab for ungrouped Resources/Pages plus Widgets/Custom Permissions, and the ui.group_by_navigation on/off switch — is what ClubManager's own RoleResource implements; treat the sketch above as a starting point, not a drop-in.
Configuration
// config/filament-shield-enhanced.php return [ 'pages' => [ // First segment of the three-part key: Page:Action:Subject 'permission_prefix' => 'Page', ], 'components' => [ // First segment of the three-part key: Component:Action:Subject 'permission_prefix' => 'Component', // Directories scanned for classes using HasComponentShield, each // mapped to its base namespace. Add more entries if components // live outside app/Livewire. 'scan_paths' => [ app_path('Livewire') => 'App\\Livewire', ], ], 'ui' => [ 'grid_columns' => [ 'default' => 1, 'sm' => 2, 'lg' => 3, ], 'checkbox_list_columns' => [ 'default' => 1, 'sm' => 2, ], // None of the three keys below are read by make() itself — make() // always groups by navigation order and always calls things // "Resources"/"Pages". They exist purely as a shared config contract // for apps implementing the §8c recipe; nothing happens unless your // own getShieldFormComponents() reads them (as the §8c snippet does). 'group_by_navigation' => true, 'group_sort' => 'navigation', // 'navigation' | 'alphabetical' // End users configuring roles don't know what a Filament "Resource" // or "Page" is — override with labels that describe what the // category lets someone DO. The defaults already avoid the raw // Filament terms; treat them as a starting point, not a fixed set. 'labels' => [ 'resources' => 'Management Areas', 'pages' => 'Function Pages', 'widgets' => 'Dashboard Widgets', 'custom' => 'Other Permissions', 'misc_group' => 'Miscellaneous', ], ], ];
Localization
Section titles come from each Resource's/Page's own getModelLabel() / navigation label — if your app already localizes those, they localize here too. Everything this addon itself adds — permission labels/descriptions passed to getShieldResourcePermissions() / getShieldPagePermissions() / getShieldPermissionDescriptions(), and the ui.labels.* config values from §8c — are plain strings, not routed through __(). If your app supports multiple locales, wrap them yourself (__('permissions.squad_view_hint') instead of a literal string) — this addon won't do it for you. Shield's own standard CRUD labels (View, Create, Update, …) and RoleResource chrome (Save changes, Select All, …) already come from filament-shield's own translated lang files independently of this addon.
How it works internally
This addon does not override any class from filament-shield. Instead it uses the package's public extension point:
FilamentShield::buildPermissionKeyUsing(function (...) { ... });
When a Page class exposes getShieldPagePermissions(), the addon intercepts the key builder and applies its three-part naming convention. All other entities (Resources, Widgets, regular Pages) are delegated back to the original builder unchanged.
Resource permissions use a two-part format matching Shield's own convention and are not created via shield:generate — only via shield:generate-enhanced-resources. This means the hook is not involved for Resources at all.
Component permissions work the same way as Resources with respect to the hook — the hook is not involved, since arbitrary Livewire components were never part of Shield's Page/Resource/Widget discovery pipeline in the first place. shield:generate-enhanced-components discovers them independently by scanning the configured components.scan_paths for classes using HasComponentShield, rather than iterating a panel's registered entities.
Changelog
See CHANGELOG.md.
License
MIT — see LICENSE.md.
Credits
- Alexander Groezinger — addon author
- Bezhan Salleh — original filament-shield package

