Updated 3 July 2026

Aureus ERP is a modular ERP built on Laravel and Filament. The moment many roles share one panel, a single question decides if it is usable: who can see which records?
Standard permission tooling answers only half of that. This post covers the other half, and how the Aureus ERP Security module solved it.
Most Laravel permission setups, including Filament Shield on top of Spatie laravel-permission, model access as a yes/no flag per action:
view_sales_order, create_sales_order, update_sales_order, delete_sales_order …
That works for features. If a user has view_sales_order, Shield lets them open the screen. The catch is that it lets them see every sales order in the company.
An ERP needs something finer. A salesperson should see only their own orders. A team lead should see their Team’s. An admin should see everything.

So the requirement was record-level access control, layered on the normal permission check, and applied across every module without rewriting each screen.
To be precise about the gap in default Filament Shield:
However, we did not want to drop Shield or Spatie, since roles still do their job. We needed to extend them with a scope.
The whole design rests on one small enum. Every user carries a resource_permission that says how wide their access is:
|
1 2 3 4 5 6 |
enum PermissionType: string { case GROUP = 'group'; // see records owned by my team(s) case INDIVIDUAL = 'individual'; // see only my own records case GLOBAL = 'global'; // see everything } |
This is the piece Shield lacks: a scope axis on top of the yes/no permission. A role grants what you can do. The permission type decides whose records you can do it to.
The scope is useless unless every list and dropdown respects it. An Eloquent global scope reads the user’s resource_permission and filters accordingly:

|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class UserPermissionScope implements Scope { public function apply(Builder $builder, Model $model): void { $user = Auth::user(); if (! $user?->resource_permission) { Return; } // GLOBAL -> no filter, see everything if ($user->resource_permission === PermissionType::GLOBAL) { return; } // INDIVIDUAL -> only records the user owns (or follows in Chatter) if ($user->resource_permission === PermissionType::INDIVIDUAL) { $builder->whereHas($this->ownerRelation, fn ($q) => $q->where('users.id', $user->id) ); $builder->orWhereHas('followers', fn ($q) => $q->where('chatter_followers.partner_id', $user->partner_id) ); } // GROUP -> records owned by anyone on the user's team(s) if ($user->resource_permission === PermissionType::GROUP) { $teamIds = $user->teams()->pluck('id'); $builder->whereHas("$this->ownerRelation.teams", fn ($q) => $q->whereIn('teams.id', $teamIds) ); } } } |
Note the touch on INDIVIDUAL: you also see records you follow in Chatter. Collaboration still works even under the tightest scope.
To switch this on for a Filament resource, we drop in one trait that overrides the base query:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
trait HasResourcePermissionQuery { public static function getEloquentQuery(): Builder { $query = parent::getEloquentQuery(); if (method_exists($query->getModel(), 'scopeApplyPermissionScope')) { return $query->applyPermissionScope(); } return $query; } } |
That is the whole integration cost per screen. There is no per-resource access logic to maintain.
Each scope needs a different set of allowed users. A small Bouncer service (via a bouncer() helper) resolves and caches that set, so it is not recomputed on every query:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Bouncer { public function getAuthorizedUserIds(): ?array { $user = filament()->auth()->user(); if ($user->resource_permission == PermissionType::GLOBAL) { return null; // null = no restriction } if ($user->resource_permission == PermissionType::GROUP) { return $this->getCurrentAccessibleUserIds($user); // team members } return [$user->id]; // individual = just me } } |
Models opt in with a flexible HasPermissionScope trait. It knows how each model stores ownership, whether an owner column (creator_id), an assignment column (user_id), or a pivot table.
Query scoping hides records from lists. We also need to authorize an action on one record. That policy layer uses a complementary HasScopedPermissions trait:
|
1 2 3 4 5 6 |
protected function hasAccess(User $user, Model $model, string $ownerAttribute = 'user'): bool { return $this->hasGlobalAccess($user) || $this->hasGroupAccess($user, $model, $ownerAttribute) || $this->hasIndividualAccess($user, $model, $ownerAttribute); } |
Same three-way logic of global, group, and individual. Now it answers “may this user touch this record?” inside the policies.
We also extended Spatie’s PermissionRegistrar to wire a gate before hook and team-aware checks. Spatie stays the foundation while fitting the ERP’s needs.
No. Filament Shield and Spatie laravel-permission grant permissions globally per action. They decide if a user can view a resource, not which records. Aureus ERP adds that layer with a permission type and an Eloquent global scope.
Role permissions define actions like view, create, update, and delete. Record-level permissions define which records those actions apply to, such as your own, your team’s, or all records.
Each user has a resource_permission value of Global, Group, or Individual. A global Eloquent scope automatically filters queries based on that access level.
Filament Shield manages feature permissions, while Aureus ERP extends it with record-level permissions. This ensures users see only the data they’re allowed to access.
A global query scope, policy checks, and a cached Bouncer provide consistent, team-aware access across every module with minimal boilerplate.
Want to see how Aureus ERP can fit your team’s access rules? Tell us about your company and we will walk you through it.
Further reading: Spatie laravel-permission documentation, Filament Shield plugin, Laravel Eloquent query scopes
Tell us about Your Company

If you have more details or questions, you can reply to the received confirmation email.
Back to Home
Be the first to comment.