You ship a client portal next to your admin panel. Staff need CMS and CRM tools. Clients need their projects, invoices, tickets, and files.
Someone copies the staff role matrix into the portal. A client hits /admin, or changes an ID and reads another account's ticket.
This guide is about portal RBAC in Laravel: how to separate portal users from staff, how Spatie laravel-permission fits that split, and where policies still own "this invoice belongs to that account." It is not a generic Spatie tutorial. For staff-admin UI, middleware, and impersonation, use the companion staff guide on LaraDashboard. Here we stay on the portal surface.
Disclosure up front: we build LaraDashboard, an open-source Laravel admin and CMS. We recommend it for the staff half when you want Spatie-backed roles, modules, and activity logging in one admin. You still own portal routes, membership, and account-scoped policies. The same patterns apply if you use Filament, Nova, or a custom admin.
Staff RBAC is not portal RBAC
Staff admin RBAC answers which internal users can manage content, settings, users, and modules.
Client portal RBAC answers which external users can view or act on their resources, and never on staff-only surfaces.
Spatie gives you roles, permissions, middleware, Blade directives, and optional multiple guards. Laravel policies still decide resource ownership. "Logged in" is not "trusted like staff."
If the portal is a thin "view my invoices" page, a few permissions plus policies may be enough. If you have client admins, viewers, partners, and staff impersonation, write the portal role map before you invent another is_admin flag.
Portal users are not staff with fewer checkboxes
The common shortcut is one users table and one role list: Super Admin, Admin, Editor, Client. You hide admin nav with @can and hope clients never hit /admin.
That fails in three predictable ways:
-
Route leakage. A guessed admin URL still runs if middleware only checks
auth. -
Permission name collisions.
projects.viewmeans "all projects" for staff and "mine" for clients unless policies enforce ownership. - Role assignment mistakes. Support grants a temporary staff role during debugging and forgets to revoke it.
Same Eloquent model is fine. Same permission namespace for staff-only actions is not. Treat portal actors as a different audience.
Two surfaces, one Laravel app
Most products end up with:
-
Staff surface:
/admin, session auth, Spatie roles for CMS/CRM, activity log, maybe impersonation. -
Portal surface:
/portalor a subdomain, session or Sanctum auth, portal roles (client-admin,client-member,client-billing), policies filtered byaccount_id/organization_id.
You can implement both in one codebase. Do not reuse staff permission strings as the only line of defense for portal writes.
Cheat sheet:
- Staff goal: operate the product. Portal goal: consume and update own account data.
- Staff default deny: no module permission. Portal default deny: no membership on the account.
- Staff failure mode: over-privileged editor. Portal failure mode: IDOR across accounts.
RBAC and tenancy are related. They are not the same layer.
What to name in Spatie
Prefer permission names that encode the portal audience:
- Good:
portal.projects.view,portal.projects.update,portal.invoices.pay,portal.users.invite - Risky: bare
projects.viewfor both staff "all" and client "mine"
Roles group those permissions:
-
client-admin: invite members, manage billing contacts, view all account projects -
client-member: view and update assigned projects only -
client-billing: invoices and payment methods -
client-viewer: read-only across the account
Keep staff roles (super-admin, editor, support) in a separate naming family. Do not invent a "client-super-admin" that maps to staff middleware.
Seed portal roles in code so production permission strings are not casually renamed in a UI. Optionally expose a subset of portal role management to trusted support users.
Guards: when multiples help
Spatie supports multiple guards as namespaces. Creating edit articles for web does not create it for admin. Assignments must match the user's guard or you get GuardDoesNotMatch errors.
Separate portal guard helps when: portal users are a different authenticatable model, you want hard isolation so staff roles cannot land on portal sessions, or session cookies are already split.
One guard is enough when: one User model with membership, you force a single $guard_name, and route middleware plus policies do the isolation.
Multiple guards add ceremony. Single guard is simpler but demands ironclad middleware on /admin and /portal. Pick one strategy and document it. Mixing "sometimes check guard, sometimes check role name" is how incidents start.
Policies still own account scope
Roles answer capability classes. Policies answer resource instances.
Put rules like these in policies (or FormRequest authorize()), not only in Blade:
- User may view
Invoice $invoiceonly if they belong to that account and haveportal.invoices.view - User may update
Project $projectonly if they areclient-adminor the project is assigned to them - User may never delete staff users, even with a mis-assigned permission string
A permission without a membership check is classic IDOR: change the ID in the URL, read another tenant's ticket. Hide UI with @can, but never trust UI hiding as security.
Six failure cases
Guard mixups.
PermissionDoesNotExistafter adding aportalguard, or checks use the wrong permission set. Setguard_nameexplicitly. Pass the guard on checks when you use multiples.Super-admin leak. A
Gate::beforethat returns true forsuper-adminalso runs on portal routes. Scope god-mode bypass to staff middleware groups only.Missing policies (IDOR).
middleware('permission:portal.projects.view')thenProject::findOrFail($id)with no account scope. PreferforAccount($accountId)->findOrFail($id)plus$this->authorize('view', $project). Add feature tests that swap account IDs.Staff permission reuse. Client role includes
users.managebecause a seeder reused the staff matrix. Separate catalogs. Code-review seeders.Session fixation across surfaces. One session guard serves both route groups. Separate login endpoints, regenerate sessions, reject the wrong audience in middleware. Pair with 2FA for staff.
API tokens without portal abilities. Sanctum token with
*or staff permission names. Map abilities to portal permissions only. Revoke on offboarding.
Where LaraDashboard fits
Disclosure again: LaraDashboard ships staff-facing RBAC on Spatie: role UI, permission groups (including module-scoped permissions), middleware, policies, Blade directives, activity logging, and guarded impersonation. That is the foundation for the admin half of a client-portal product.
What you still build for the portal half:
- Portal route group and views
-
portal.*role/permission seeders - Account membership model
- Policies that enforce account scope
- Optional separate guard if authenticatable models differ
- Tests for IDOR and role escalation
LaraDashboard does not replace your portal UX or tenancy rules. It reduces the cost of running a secure staff admin next to that portal.
Suggested build order
- Write the role map on paper. Staff vs portal. No shared "admin" name for both.
- Seed
portal.*permissions. Attach to portal roles only. - Lock staff routes with staff middleware (and/or admin guard). Confirm clients get 403 on
/admin. - Add membership + policies before fancy portal UI.
- Add UI that only shows actions the policy allows.
- Log role changes.
- Test cross-account access, guard mismatch, and super-admin bypass.
Skip step 3 and you debug clients inside settings. Skip step 4 and you debug silent data leaks.
Ending note
Client portal security fails when you treat portal users as "staff with fewer boxes ticked." Separate the audiences in naming, middleware, and policies. Use Spatie for role storage. Use Laravel policies for account-scoped resources. Use LaraDashboard for the staff admin with auditable RBAC while you keep portal rules in your app code.
For the full walkthrough with FAQ and deeper guard tradeoffs, read the canonical post:
https://laradashboard.com/blog/role-based-access-for-client-portals-with-spatie-and-laradashboard
If you want a Laravel admin that already speaks Spatie roles for the staff side, try the demo or docs on laradashboard.com.
Top comments (0)