The security report was two lines long: "Changing the post ID in PUT /api/posts/{id} lets any logged-in user edit any post." The web controller had the check, if ($post->user_id !== auth()->id()) abort(403);, copied by hand. The API controller, written six months later by someone else, did not. Neither did the bulk-publish action in the admin panel. Every new rule ("editors can edit any post in their team", "nobody edits a published post after 24 hours") had to be patched into fourteen places, and the fifteenth place is always the one that leaks data.
Laravel already has the answer: Gates and Policies. They move every "is this user allowed to do this?" decision into one place, give it a name, and let controllers, Form Requests, routes, Blade and tests all ask the same question the same way. The hard part is not the API; it is using it consistently and knowing its sharp edges.
Gates vs Policies
Both answer the same question, they just organise it differently:
- A Gate is a named closure, usually defined in
AppServiceProvider::boot(). Good for abilities that are not about one model: "view the reports dashboard", "access Horizon", "impersonate users". - A Policy is a class grouping the abilities for one model:
PostPolicy::view,update,deleteand so on. Good for anything that involves a specific record.
Under the hood, policies are registered with the same Gate service. Gate::allows('update', $post) looks at the type of $post, finds PostPolicy, and calls PostPolicy::update($user, $post). That is why $user->can(...), @can, Gate::authorize(...) and route ->can(...) all work identically with both.
How a Check Is Resolved
The order matters. Before callbacks run first and can short-circuit everything: return true to allow, false to deny, null to say "no opinion, continue". Then the policy method (or gate closure) decides. If the user is a guest and the method's $user parameter is not nullable, Laravel denies without even calling it. A missing policy method also means deny. Laravel fails closed, which is the behaviour you want.
Writing a Policy
Generate it with php artisan make:policy PostPolicy --model=Post. Since Laravel 11, policies in App\Policies named after a model in App\Models are discovered automatically, so there is nothing to register.
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class PostPolicy
{
public function viewAny(?User $user): bool
{
return true;
}
public function view(?User $user, Post $post): Response
{
if ($post->published_at !== null) {
return Response::allow();
}
return $user?->id === $post->user_id
? Response::allow()
: Response::denyAsNotFound();
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
public function update(User $user, Post $post): Response
{
if ($user->id !== $post->user_id && ! $user->isEditorOf($post->team_id)) {
return Response::deny('You can only edit your own posts.');
}
if ($post->published_at?->lt(now()->subDay())) {
return Response::deny('Published posts are locked after 24 hours.');
}
return Response::allow();
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function restore(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function forceDelete(User $user, Post $post): bool
{
return false;
}
}
A few choices in there are deliberate:
?User $useronviewAnyandviewlets guests through to the method. Without the?, guests are denied automatically.Response::denyAsNotFound()returns a 404 instead of a 403 for drafts. A 403 confirms that a draft with that ID exists, which is itself an information leak.Response::deny('...')carries a message that ends up in the 403 response and inGate::inspect(), which is far more useful than a barefalsewhen support asks why an editor cannot save.forceDeletereturnsfalsefor everyone; the before hook below lets admins through.
Before Hooks: Super Admins Without Polluting Every Method
Admins usually can do everything. Writing || $user->isAdmin() in every method is noise and easy to forget. Use a before hook instead, either per policy or globally.
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Gate::before(function (User $user, string $ability): ?bool {
return $user->is_super_admin ? true : null;
});
Gate::define('view-reports', function (User $user): bool {
return $user->hasRole('finance') || $user->hasRole('manager');
});
}
}
The single most important detail: return null, not false, when the hook has no opinion. Returning false denies every ability for every non-admin user, and that bug usually ships because the developer testing it was an admin.
A policy can also define public function before(User $user, string $ability): ?bool. It runs only for that policy, and only if the policy actually has a method for the ability being checked. Use it for rules like "banned users can do nothing with posts".
Where to Authorize
Authorize as close to the entry point as possible, and pick one style per entry point so reviewers know where to look.
In a controller, call Gate::authorize(). It throws AuthorizationException, which Laravel renders as 403 (or 404 for denyAsNotFound). Laravel 11+ controllers no longer extend a base class with AuthorizesRequests, so $this->authorize() only works if you add that trait yourself; the facade always works.
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class PostPublishController
{
public function __invoke(Post $post): RedirectResponse
{
Gate::authorize('update', $post);
$post->update(['published_at' => now()]);
return to_route('posts.show', $post);
}
}
When a Form Request already validates the input, its authorize() method is the natural place. It runs before validation, so unauthorized users do not even learn which fields are invalid.
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'body' => ['required', 'string'],
];
}
}
For simple cases, authorize on the route itself: Route::put('/posts/{post}', UpdatePostController::class)->can('update', 'post');. For abilities without a model instance, pass the class: ->can('create', Post::class).
In Blade, @can hides buttons users cannot use. That is a UX feature, not security: the server-side check must exist anyway.
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Edit</a>
@endcan
@cannot('delete', $post)
<p class="text-sm text-gray-500">Only the author can delete this post.</p>
@endcannot
Testing Policies
Test policies at two levels. Unit-style tests call the policy through the gate so before hooks are included; feature tests prove that the endpoint actually enforces it. The second kind is what catches the forgotten API controller.
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('lets authors update their own posts', function () {
$author = User::factory()->create();
$post = Post::factory()->for($author)->create(['published_at' => null]);
expect($author->can('update', $post))->toBeTrue();
});
it('forbids other users from updating a post', function () {
$post = Post::factory()->create();
$stranger = User::factory()->create();
expect($stranger->can('update', $post))->toBeFalse();
$this->actingAs($stranger)
->putJson("/api/posts/{$post->id}", ['title' => 'Hacked', 'body' => 'x'])
->assertForbidden();
});
it('hides drafts from other users with a 404', function () {
$draft = Post::factory()->create(['published_at' => null]);
$this->actingAs(User::factory()->create())
->getJson("/api/posts/{$draft->id}")
->assertNotFound();
});
it('lets super admins force delete through the before hook', function () {
$admin = User::factory()->create(['is_super_admin' => true]);
$post = Post::factory()->create();
expect($admin->can('forceDelete', $post))->toBeTrue()
->and(User::factory()->create()->can('forceDelete', $post))->toBeFalse();
});
A useful habit: for every route that takes a model, write one "stranger gets 403/404" test. It is cheap, and it is exactly the test that would have caught the original IDOR.
Common Pitfalls
- Returning
falsefrom a before hook. It denies everything for everyone the hook does not explicitly allow. Returnnullto fall through. - Authorizing in the view only.
@canhides a button; the endpoint is still reachable with curl. - Assuming policies filter queries.
PostPolicy::viewdoes not stopPost::all()from returning other users' drafts. List endpoints need query scopes (->whereBelongsTo($user)) that mirror the policy rules. - Forgetting guests. A non-nullable
User $usermeans guests are always denied, which is right for writes but wrong for public pages. - Calling
$this->authorize()in a Laravel 11+ controller. Without theAuthorizesRequeststrait it is an undefined method. UseGate::authorize()or add the trait. - 403 that reveals existence. For private resources, prefer
denyAsNotFound(). - Business rules leaking into controllers again. If a check needs the user and the model, it belongs in the policy, even if it is "just one line".
When to Use Gates, Policies, or Neither
- Use a policy for anything about a specific model or model type: CRUD, publish, archive, export one record.
- Use a gate for app-wide abilities with no model: dashboards, admin tools, feature access by role.
- Use a before hook for cross-cutting overrides like super admins or banned users, and keep it to a few lines.
- Do not use either for validation ("title must be unique") or for data scoping on lists; those belong to Form Request rules and query scopes.
- For complex role and permission matrices managed in the database, a package like
spatie/laravel-permissioncan feed into the same gate, so$user->can()keeps working everywhere.
The real win is not syntax; it is that "who can edit a post?" has exactly one answer in the codebase, and every entry point, view and test asks that same answer.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Build a tiny authorization gate that mirrors how Laravel resolves abilities. Users are plain arrays (PHP) or dicts (Python), for example ['id' => 1, 'role' => 'admin']. Class Gate with: - define(string $ability, Closure $callback): void registers an ability. The callback receives ($user, ...$args). - before(Closure $callback): void adds a before hook. Hooks receive ($user, $ability, ...$args) and run in the order they were added. - allows(?array $user, string $ability, mixed ...$args): bool. Rules in order: a null user (guest) is denied and no callback is called; the first before hook that returns a non-null value decides, and only === true allows; an undefined ability is denied; otherwise the ability callback decides, and only === true allows (truthy values like 1 deny). - denies(...) is the opposite of allows(...). - authorize(...) returns nothing when allowed and throws AuthorizationException with the message 'This action is unauthorized.' when denied. Python: same API with snake_case names, user: dict | None, *args, and AuthorizationError instead of AuthorizationException. 'Only True allows' means the result must be the True object.
Starter code
final class AuthorizationException extends RuntimeException {}
final class Gate
{
/** @var array<string, Closure> */
private array $abilities = [];
/** @var list<Closure> */
private array $beforeCallbacks = [];
public function define(string $ability, Closure $callback): void
{
// TODO: remember $callback for $ability
}
public function before(Closure $callback): void
{
// TODO: append $callback to the before callbacks
}
public function allows(?array $user, string $ability, mixed ...$args): bool
{
// TODO: guests (null user) are always denied, no callback is called
// TODO: run before callbacks in order with ($user, $ability, ...$args);
// the first non-null result decides (allowed only if it is === true)
// TODO: undefined ability => false
// TODO: otherwise call the ability callback with ($user, ...$args); allowed only if === true
return false;
}
public function denies(?array $user, string $ability, mixed ...$args): bool
{
// TODO: the opposite of allows()
return true;
}
public function authorize(?array $user, string $ability, mixed ...$args): void
{
// TODO: throw AuthorizationException('This action is unauthorized.') when denied
}
}
from collections.abc import Callable
from typing import Any
class AuthorizationError(Exception):
pass
class Gate:
def __init__(self) -> None:
self._abilities: dict[str, Callable[..., Any]] = {}
self._before: list[Callable[..., Any]] = []
def define(self, ability: str, callback: Callable[..., Any]) -> None:
# TODO: remember callback for ability
raise NotImplementedError
def before(self, callback: Callable[..., Any]) -> None:
# TODO: append callback to the before callbacks
raise NotImplementedError
def allows(self, user: dict | None, ability: str, *args: Any) -> bool:
# TODO: guests (None user) are always denied, no callback is called
# TODO: run before callbacks in order with (user, ability, *args);
# the first non-None result decides (allowed only if it is True)
# TODO: undefined ability -> False
# TODO: otherwise call the ability callback with (user, *args); allowed only if it is True
raise NotImplementedError
def denies(self, user: dict | None, ability: str, *args: Any) -> bool:
# TODO: the opposite of allows()
raise NotImplementedError
def authorize(self, user: dict | None, ability: str, *args: Any) -> None:
# TODO: raise AuthorizationError("This action is unauthorized.") when denied
raise NotImplementedError
Your solution
Hints
All hints revealedCheck the guest case first so no hook or ability callback ever receives null.
In the before loop, skip results that are null and return immediately on the first non-null one.
Use strict comparison (=== true in PHP, is True in Python) so truthy values such as 1 do not grant access.
Implement denies and authorize on top of allows so the rules live in one place.
final class AuthorizationException extends RuntimeException {}
final class Gate
{
/** @var array<string, Closure> */
private array $abilities = [];
/** @var list<Closure> */
private array $beforeCallbacks = [];
public function define(string $ability, Closure $callback): void
{
$this->abilities[$ability] = $callback;
}
public function before(Closure $callback): void
{
$this->beforeCallbacks[] = $callback;
}
public function allows(?array $user, string $ability, mixed ...$args): bool
{
if ($user === null) {
return false;
}
foreach ($this->beforeCallbacks as $before) {
$result = $before($user, $ability, ...$args);
if ($result !== null) {
return $result === true;
}
}
if (! isset($this->abilities[$ability])) {
return false;
}
return ($this->abilities[$ability])($user, ...$args) === true;
}
public function denies(?array $user, string $ability, mixed ...$args): bool
{
return ! $this->allows($user, $ability, ...$args);
}
public function authorize(?array $user, string $ability, mixed ...$args): void
{
if ($this->denies($user, $ability, ...$args)) {
throw new AuthorizationException('This action is unauthorized.');
}
}
}
from collections.abc import Callable
from typing import Any
class AuthorizationError(Exception):
pass
class Gate:
def __init__(self) -> None:
self._abilities: dict[str, Callable[..., Any]] = {}
self._before: list[Callable[..., Any]] = []
def define(self, ability: str, callback: Callable[..., Any]) -> None:
self._abilities[ability] = callback
def before(self, callback: Callable[..., Any]) -> None:
self._before.append(callback)
def allows(self, user: dict | None, ability: str, *args: Any) -> bool:
if user is None:
return False
for callback in self._before:
result = callback(user, ability, *args)
if result is not None:
return result is True
callback = self._abilities.get(ability)
if callback is None:
return False
return callback(user, *args) is True
def denies(self, user: dict | None, ability: str, *args: Any) -> bool:
return not self.allows(user, ability, *args)
def authorize(self, user: dict | None, ability: str, *args: Any) -> None:
if self.denies(user, ability, *args):
raise AuthorizationError("This action is unauthorized.")

