I can smell a Laravel app that grew authorization in the controller. It starts innocent: one $project->user_id === $user->id check in update, then a second one in destroy, then a special case for “admins can do anything,” then a teammate who can edit if they are in project_user, then a JSON endpoint that forgot the check entirely. Six months later, every write action has a slightly different story about who is allowed to touch the row.
Policies and gates are not ceremony. They are how I keep one authorization truth that controllers, Form Requests, Blade, and API resources can all ask. This is the shape I trust before I let a SaaS controller accumulate secret if trees.
What I refuse to call “secured”
A green feature test that hits a happy path as the owner is not an authorization model. Before I call a write endpoint done, I want:
- A named ability (
update,delete,inviteMember) that lives on a policy or gate — not a private controller method reused by copy-paste. - The same denial path for web and API —
403with a consistent body, not a redirect for Blade and a silent404for JSON unless I chose that on purpose. - Ownership and role rules that are testable without booting the whole HTTP stack — policy unit tests, plus a few HTTP tests for wiring.
- Form Requests that authorize before they validate — so a malicious payload never reaches domain code just because the JSON looked valid.
- No “admin bypass” sprinkled as
|| $user->isAdmin()in twelve places — admins go through the same policy method, usually with an early return that is still named and logged when it matters.
If authorization is only “we check user_id in the controller,” you do not have a policy — you have a habit that will miss the next endpoint.
Policies for resource shape, gates for app-wide verbs
I split the two tools by what they answer:
| Tool | Question it answers | Typical home |
|---|---|---|
| Policy | “Can this user perform this action on this model?” | ProjectPolicy, InvoicePolicy |
| Gate | “Can this user perform this app-wide action?” | viewHorizon, accessBilling, impersonate |
| Form Request | “Is this HTTP payload allowed and valid for this action?” | UpdateProjectRequest |
Policies attach to Eloquent models. Gates attach to verbs that are not one model instance — or to ops tools that should stay out of UserPolicy. Form Requests are the HTTP front door: they call $this->user()->can(...) (or $this->authorize(...)) so controllers stay thin.
When a teammate asks “where do I add the rule that only billing owners can export invoices?”, I should be able to point at one method, not a grep across controllers.
The policy I start with on every owned resource
Assume a multi-tenant-ish project board: a Project belongs to an owner, has members, and has statuses that lock edits. This is the skeleton I reach for first:
<?php
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
class ProjectPolicy
{
public function viewAny(User $user): bool
{
return true; // listing is filtered in the query; see below
}
public function view(User $user, Project $project): bool
{
return $this->isMember($user, $project);
}
public function create(User $user): bool
{
return $user->can('projects.create'); // gate or permission package
}
public function update(User $user, Project $project): bool
{
if ($project->isArchived()) {
return false;
}
return $this->isOwnerOrEditor($user, $project);
}
public function delete(User $user, Project $project): bool
{
return $this->isOwner($user, $project);
}
public function inviteMember(User $user, Project $project): bool
{
return $this->isOwnerOrEditor($user, $project)
&& ! $project->isArchived();
}
private function isOwner(User $user, Project $project): bool
{
return (int) $project->owner_id === (int) $user->id;
}
private function isMember(User $user, Project $project): bool
{
if ($this->isOwner($user, $project)) {
return true;
}
return $project->members()->where('user_id', $user->id)->exists();
}
private function isOwnerOrEditor(User $user, Project $project): bool
{
if ($this->isOwner($user, $project)) {
return true;
}
return $project->members()
->where('user_id', $user->id)
->whereIn('role', ['editor', 'owner'])
->exists();
}
}
A few opinions baked in:
- Archive is a hard deny for writes, not a soft UI hide. Policies are where “the resource state forbids mutation” belongs.
viewAnyis rarely where I filter the list. Listing scopes belong on the query (Project::query()->visibleTo($user)). The policy answers “may they hit the index at all?”; the query answers “which rows?”- Custom abilities (
inviteMember) beat stuffing every product verb intoupdate. Controllers and Form Requests stay readable when the ability name matches the product language.
Register it the boring way — Laravel’s auto-discovery usually finds Project → ProjectPolicy. If I ever hand-register, I do it in AuthServiceProvider once and never again in a controller constructor.
Controllers that ask, then act
The controller I trust looks almost empty on authorization:
public function update(UpdateProjectRequest $request, Project $project)
{
$project->fill($request->validated())->save();
return new ProjectResource($project->fresh());
}
public function destroy(Project $project)
{
$this->authorize('delete', $project);
$project->delete();
return response()->noContent();
}
UpdateProjectRequest owns the update ability. destroy can use $this->authorize directly when there is no body to validate. What I do not want:
// The pattern I delete on sight.
if ($project->owner_id !== auth()->id() && ! auth()->user()->isAdmin()) {
abort(403);
}
That if will be reimplemented wrong in the next action. Put the admin rule in the policy (or a before hook — carefully) so every ability inherits it on purpose.
before hooks: powerful, easy to regret
public function before(User $user, string $ability): ?bool
{
if ($user->hasRole('super-admin')) {
return true;
}
return null; // fall through to the ability method
}
I use before for a narrow break-glass role, not for “every staff user.” Super-admin bypass that skips delete on legal holds is how you get a compliance incident with a clean audit log that says “policy said yes.” Prefer ability-specific admin checks when the domain has irreversible actions.
Form Requests as the ownership gate for writes
Validation without authorization is how attackers probe for field mass-assignment while your policy sits unused in a controller that never got called the way you imagined. My default Form Request:
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProjectRequest extends FormRequest
{
public function authorize(): bool
{
/** @var Project $project */
$project = $this->route('project');
return $this->user()->can('update', $project);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:5000'],
'status' => ['required', 'in:active,paused'],
];
}
protected function prepareForValidation(): void
{
// Normalize — never authorize here.
$this->merge([
'name' => trim((string) $this->input('name')),
]);
}
}
Notes I enforce in review:
authorize()runs beforerules()in Laravel’s Form Request lifecycle. Good — keep it that way in your head when debugging.- Route model binding gives you the instance. Do not re-query by an untrusted
project_idfrom the body for the auth check. - Never put ownership rules only in
rules()with a fancyRule::exists. Exists checks are not authorization. A row can exist and still be none of your business.
For create endpoints, authorize() often maps to create on the policy with the class name:
return $this->user()->can('create', Project::class);
Gates for verbs that are not one model
Horizon’s viewHorizon gate, billing portal access, “can impersonate,” “can run dangerous artisan from the UI” — those are gates. Example:
use Illuminate\Support\Facades\Gate;
Gate::define('accessBilling', function (User $user) {
return $user->isOwnerOfCurrentTeam()
|| $user->tokenCan('billing:read'); // Sanctum ability if token auth
});
Gate::define('impersonate', function (User $user) {
return $user->hasRole('support') && $user->mfa_confirmed_at !== null;
});
I keep gates small and boring. When a gate starts loading five relationships and interpreting a JSON permissions column, I promote that logic into a dedicated action or a policy on a Team model. Gates that become mini-ORMs are how authorization becomes undebuggable.
Query scopes so list endpoints cannot leak
Authorization on show/update is useless if index returns every row in the table. I pair policies with a scope:
// app/Models/Project.php
public function scopeVisibleTo($query, User $user)
{
return $query->where(function ($q) use ($user) {
$q->where('owner_id', $user->id)
->orWhereHas('members', fn ($m) => $m->where('user_id', $user->id));
});
}
// controller
public function index(Request $request)
{
$this->authorize('viewAny', Project::class);
$projects = Project::query()
->visibleTo($request->user())
->latest()
->paginate(20);
return ProjectResource::collection($projects);
}
If someone removes the scope, tests must fail. I treat “index without visibleTo” as a security bug, not a style nit.
API tokens and policies: same abilities, different actor
With Sanctum (or Passport), the actor is still a User (or a tokenable model), but token abilities are an extra dimension. I do not replace policies with tokenCan alone. I compose them:
public function update(User $user, Project $project): bool
{
if ($user->currentAccessToken()
&& ! $user->tokenCan('projects:write')) {
return false;
}
return $this->isOwnerOrEditor($user, $project);
}
Or keep token ability checks at the route middleware (ability:projects:write) and let the policy stay about ownership and state. Pick one layering and document it — mixing “middleware checks token” with “policy also checks token differently” is how mobile clients get mysterious 403s after a scope rename.
Blade and Inertia: hide buttons, still enforce on the server
I use @can / <Authorize> to hide UI. I never trust the UI:
@can('update', $project)
<a href="{{ route('projects.edit', $project) }}">Edit</a>
@endcan
A disabled button is not a security boundary. The Form Request and policy are. When I ship admin UIs for products — including agency work I have done around shipping calendars and client portals for teams like SquartUp — the same rule holds: the browser can lie; the policy must not.
Tests I require before merge
Minimum set for a new ability:
public function test_owner_can_update_project(): void
{
$owner = User::factory()->create();
$project = Project::factory()->for($owner, 'owner')->create();
$this->assertTrue($owner->can('update', $project));
}
public function test_member_viewer_cannot_update_project(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create();
$project->members()->attach($viewer->id, ['role' => 'viewer']);
$this->assertFalse($viewer->can('update', $project));
}
public function test_update_endpoint_returns_403_for_viewer(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create();
$project->members()->attach($viewer->id, ['role' => 'viewer']);
$this->actingAs($viewer)
->patchJson(route('projects.update', $project), ['name' => 'Nope', 'status' => 'active'])
->assertForbidden();
}
public function test_index_does_not_leak_foreign_projects(): void
{
$user = User::factory()->create();
Project::factory()->create(); // someone else's
$mine = Project::factory()->for($user, 'owner')->create();
$this->actingAs($user)
->getJson(route('projects.index'))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $mine->id);
}
Policy unit assertions are cheap. The HTTP 403 test proves the Form Request (or authorize call) is actually wired. The index leak test is the one that saves you when a junior removes a scope “to make the test pass.”
Anti-patterns I delete in review
$request->user()->id === $model->user_idin the controller after a policy already exists for that model.- Authorizing in a service class with
abort(403)buried three layers down — prefer throwingAuthorizationExceptionor returning a domain result, but decide authorization at the edge (Form Request / controller) when you can. findOrFailby body id, then policy on a different route model — classic IDOR setup.- Policies that query the world without caching membership for the request — N policy checks × N membership queries. Use
$project->setRelation('members', ...)or a request-scoped membership checker when a page authorizes many children. - “Soft” authorization — returning empty data instead of 403 for write attempts. Soft-hide is fine for list filtering; for
DELETE, I want a hard deny.
A practical rollout for a messy legacy controller
If you inherit the secret-if style, do not rewrite the universe in one PR:
- Add the policy with methods that mirror today’s controller rules (even if ugly).
- Point one Form Request at it and delete the inline
if. - Add the three tests (allow, deny, index leak if relevant).
- Move the next action —
delete, theninvite, then the JSON sibling routes. - Only then refactor membership helpers and admin
beforehooks.
Authorization refactors that change behavior without tests are how you lock out the founder on a Friday.
What I want in CODEOWNERS and PR templates
For apps where money or private data move, I ask for:
- Every new write route names a policy ability or gate in the PR description.
- Controllers stay free of ownership comparisons.
- At least one deny test per new ability.
That sounds heavy until the first IDOR ticket. Then it feels cheap.
Closing
Laravel gives you a clear stack: gates for app verbs, policies for model verbs, Form Requests for HTTP write entry, query scopes for lists. Controllers should look like they trust that stack — because they do. When I open a codebase and every update method re-explains who the owner is, I do not blame the junior who added the fifth if. I blame the missing policy.
Ship the policy first. Make the controller boring. Let the tests prove that “viewer” still means viewer after the next feature lands.