Most Laravel applications get authentication right and authorization wrong. Logging a user in is a solved problem with one command. Deciding what that user may see, edit, export or delete is bespoke logic that leaks into controllers, Blade templates, jobs, console commands and API resources — and the bugs it produces are the expensive kind: one customer reading another customer's invoices.
This tutorial lays out an authorization architecture we use on Laravel 13 codebases that has to survive a few years of feature work. It assumes a standard app with Eloquent models, some API surface, and more than one kind of user.
The three layers
Keep these separate in your head, because mixing them is what produces unmaintainable code:
- Policies and gates — the single place that answers "may this user perform this action on this record?" Everything else calls into it.
- Roles and permissions — data that policies consult. A role is a bundle of permissions; a permission is a verb. Roles never appear in a controller.
- Query scoping — the safety net that makes sure a user never even loads a record they cannot see. Policies protect the record you fetched; scoping protects you from fetching it.
If you only take one thing away: never check a role name outside a policy. if ($user->role === 'admin') sprinkled through controllers is the single most common cause of authorization rot. The day a client asks for "an admin who can't issue refunds", you are editing forty files.
Gates for app-wide abilities, policies for models
A gate answers a question that is not about a specific model:
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('view-admin-dashboard', fn (User $user) => $user->hasPermission('admin.access'));
Gate::define('impersonate', fn (User $user) => $user->is_staff);
}
A policy answers questions about a model, and Laravel 13 auto-discovers App\Policies\InvoicePolicy for App\Models\Invoice with no registration. If your naming is non-standard, attach it explicitly with the attribute:
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
#[UsePolicy(BillingDocumentPolicy::class)]
class Invoice extends Model {}
A policy that consults permissions, not role names:
<?php
namespace App\Policies;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class InvoicePolicy
{
public function viewAny(User $user): bool
{
return $user->hasPermission('invoice.view');
}
public function view(User $user, Invoice $invoice): bool
{
return $user->hasPermission('invoice.view')
&& $invoice->account_id === $user->account_id;
}
public function update(User $user, Invoice $invoice): Response
{
if ($invoice->account_id !== $user->account_id) {
return Response::denyAsNotFound();
}
if ($invoice->isLocked()) {
return Response::deny('Finalised invoices cannot be edited.');
}
return $user->hasPermission('invoice.update')
? Response::allow()
: Response::deny('You do not have permission to edit invoices.');
}
public function delete(User $user, Invoice $invoice): bool
{
return $user->hasPermission('invoice.delete')
&& $invoice->account_id === $user->account_id
&& $invoice->isDraft();
}
}
Two details worth copying. Response::denyAsNotFound() returns a 404 instead of a 403 when the record belongs to somebody else — a 403 confirms that the record exists, which is an information leak on any URL with a sequential ID. And returning Response::deny('reason') gives your UI a message to show instead of a bare "This action is unauthorized."
Use before() sparingly, and never for a generic "admin" bypass:
public function before(User $user, string $ability): ?bool
{
return $user->is_super_admin ? true : null;
}
Returning null is important: it means "no opinion, carry on". Returning false short-circuits every check in the policy, including ones you add later.
Roles and permissions without the soup
The data model is three tables and two pivots: permissions, roles, role_permission, user_role, plus optional user_permission for one-off grants. Permissions are strings in a resource.verb shape (invoice.view, invoice.refund, user.invite) — flat, greppable, and stable.
Two rules keep this from becoming unmanageable:
- Permissions are defined in code, seeded into the database. Keep the canonical list in a PHP enum or config array and sync it in a seeder or a
php artisan permissions:synccommand. A permission that only exists as a row someone typed in production is a permission nobody can find. - Roles are data, permissions are code. Clients invent roles constantly ("Regional Manager", "Read-only Auditor"). Let them build roles from existing permissions in an admin UI. Adding a permission is a code change, because some policy has to consult it.
Cache the lookup — resolving permissions per check will hammer your database on an index page with 50 rows:
public function hasPermission(string $permission): bool
{
return $this->cachedPermissions()->contains($permission);
}
protected function cachedPermissions(): \Illuminate\Support\Collection
{
return $this->permissionCache ??= Cache::remember(
"user:{$this->id}:permissions",
now()->addMinutes(15),
fn () => $this->roles()
->with('permissions:id,name')
->get()
->flatMap->permissions
->pluck('name')
->merge($this->directPermissions()->pluck('name'))
->unique()
->values(),
);
}
Then forget that key whenever a role or its permissions change — in a model observer on the pivot writes, not by hoping every call site remembers. A stale permission cache after a revoke is a security bug, so keep the TTL short and the invalidation automatic.
If you would rather not build this, spatie/laravel-permission gives you the same shape with teams support. Either way, the policies above do not change; only hasPermission() does.
Enforce it at every entry point
Authorization applied in the controller only is authorization you will forget in the job, the command and the GraphQL resolver. Push checks as close to the model as you can and use every hook Laravel gives you.
Form requests — the cleanest place, because validation and authorization fail together:
public function authorize(): bool
{
return $this->user()->can('update', $this->route('invoice'));
}
Controller helpers for anything else:
public function refund(Invoice $invoice)
{
$this->authorize('refund', $invoice);
// ...
}
Route middleware for simple resource routes:
Route::resource('invoices', InvoiceController::class)
->middleware(['auth', 'can:viewAny,App\\Models\\Invoice']);
Blade and Livewire, so the UI never offers an action that will 403:
@can('refund', $invoice)
<x-button wire:click="refund">Refund</x-button>
@endcan
In a Livewire component, re-check in the action itself. The button being hidden is a UX affordance, not a control; the request can still be forged.
API resources, so serialisation does not leak fields the user may not see:
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'total' => $this->total,
'margin' => $this->when($request->user()->can('viewMargin', $this->resource), $this->margin),
];
}
Scope every query to the tenant
Policies are per-record. They do nothing for Invoice::paginate(). Add a global scope so the wrong rows are never in the result set in the first place:
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class AccountScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($accountId = auth()->user()?->account_id) {
$builder->where($model->qualifyColumn('account_id'), $accountId);
}
}
}
In Laravel 13, attach it declaratively:
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy(AccountScope::class)]
class Invoice extends Model {}
The trap is the unauthenticated context: queue jobs, scheduled commands and console runs have no auth()->user(), so the scope silently applies nothing and a nightly job happily emails every tenant's data to one of them. Make that loud rather than silent — have the scope throw when there is no resolved tenant and no explicit opt-out, and give jobs an explicit Invoice::withoutGlobalScope(AccountScope::class)->where('account_id', $this->accountId) or a tenant-aware job middleware that sets the context before handle() runs. Our single-database multi-tenancy tutorial goes deeper on that failure mode.
Use a real database constraint too. A composite unique index on (account_id, number) and foreign keys that include the tenant column turn a whole class of logic bug into an error.
Machine clients: tokens, abilities and scopes
A Sanctum token belongs to a user but should rarely carry all of that user's power. Issue narrow abilities:
$token = $user->createToken('reporting-export', ['invoice.view'])->plainTextToken;
Then check both layers — the user's permission and the token's ability:
Route::middleware(['auth:sanctum', 'abilities:invoice.view'])
->get('/api/invoices', InvoiceIndexController::class);
Inside a policy you can consult the current token explicitly:
public function refund(User $user, Invoice $invoice): bool
{
if ($token = $user->currentAccessToken()) {
if (! $token->can('invoice.refund')) {
return false;
}
}
return $user->hasPermission('invoice.refund')
&& $invoice->account_id === $user->account_id;
}
The same principle applies to AI tooling. If you expose an MCP server or agent tools over your domain, every tool call must run through the same policies as a controller — an LLM deciding to call deleteInvoice is an untrusted request, not an internal one.
Test the denials, not the happy path
Authorization tests that only assert "the owner can edit" catch nothing. The valuable tests are the negative ones, and they are cheap to write with a dataset:
<?php
use App\Models\Invoice;
use App\Models\User;
it('hides invoices belonging to another account', function () {
$user = User::factory()->create();
$other = Invoice::factory()->create(); // different account
$this->actingAs($user)
->get("/invoices/{$other->id}")
->assertNotFound();
});
it('refuses actions the role does not grant', function (string $permission, string $method, string $uri) {
$user = User::factory()->withPermissions(['invoice.view'])->create();
$invoice = Invoice::factory()->for($user->account)->create();
$this->actingAs($user)
->call($method, str_replace('{id}', $invoice->id, $uri))
->assertForbidden();
})->with([
['invoice.update', 'PUT', '/invoices/{id}'],
['invoice.delete', 'DELETE', '/invoices/{id}'],
['invoice.refund', 'POST', '/invoices/{id}/refund'],
]);
it('never exposes margin to users without permission', function () {
$user = User::factory()->withPermissions(['invoice.view'])->create();
$invoice = Invoice::factory()->for($user->account)->create(['margin' => 1234]);
$this->actingAs($user)
->getJson("/api/invoices/{$invoice->id}")
->assertJsonMissingPath('data.margin');
});
Add one architectural test that fails the build if a role name appears outside the authorization layer:
arch('role names stay inside policies')
->expect('App\\Http\\Controllers')
->not->toUse('App\\Enums\\Role');
That single rule has saved more codebases than any amount of documentation.
Make decisions auditable
When a client asks "who could have exported this data in March?", you want an answer from records, not from reading code. Three cheap habits:
- Log denials. Listen for failed authorization and log the user, ability, model and IP at
warning. A spike in denials is either a broken UI or somebody probing. - Version role changes. Every grant and revoke gets a row: who changed it, when, from what to what.
owen-it/laravel-auditingor a small observer on the pivots is enough. - Snapshot effective permissions. A monthly job that writes each user's resolved permission set to storage turns a forensic question into a diff.
use Illuminate\Auth\Events\Failed;
use Illuminate\Support\Facades\Gate;
Gate::after(function ($user, $ability, $result, $arguments) {
if ($result === false || ($result instanceof \Illuminate\Auth\Access\Response && $result->denied())) {
logger()->warning('authorization.denied', [
'user_id' => $user?->id,
'ability' => $ability,
'subject' => is_object($arguments[0] ?? null)
? class_basename($arguments[0]).':'.($arguments[0]->id ?? null)
: null,
]);
}
});
A migration path for an app that already went wrong
If you have inherited controllers full of if ($user->role === 'admin'), do not rewrite everything at once:
- Introduce
hasPermission()and seed permissions that exactly reproduce today's role behaviour. Nothing changes functionally. - Move checks into policies one model at a time, starting with whatever holds money or personal data.
- Add the global tenant scope with logging-only mode first — log what would have been filtered for a week, fix the jobs it flags, then enforce.
- Add the architectural test last, once the offending call sites are gone, so it stays green.
Each step is shippable, and the order puts the highest-risk models behind real checks first.
We review and rebuild authorization layers on existing Laravel applications, usually as part of a security implementation or project rescue engagement. If you are not confident you could answer "who can see this record, and why?" for your own app, get in touch.