Laravel Authorization from First Principles: Gates, Policies, and the Shape of the Question

Most conversations about Laravel Gates vs. Policies start in the wrong place. They start with syntax (here’s how to define a Gate, here’s how to scaffold a Policy) and then bolt on guidance about where to use within the code. Developers learn the tools without ever understanding the design problem those tools were built to solve.

The tools make perfect sense once you see the problem clearly. So let’s start there.


The Authorization Problem, Stripped to Its Core

Every authorization check in every application you will ever build answers the same question:

Given an identity, an action, and optionally a target: is this permitted?

Three inputs. One boolean output. The entire domain fits in that sentence. Every framework feature, every design pattern, every architectural decision about where authorization logic lives is scaffolding around that core function.

What makes authorization interesting as a design problem isn’t the boolean. It’s the word optionally in front of “target.” That single word creates a fork in the road that most developers walk past without noticing. It’s also the fork that determines whether a Gate or a Policy is the right structural choice.


Two Shapes of the Same Question

When there is no target, authorization is a function of identity alone:

f(user) → bool

Can this user access the admin panel?
Can this user manage billing?
Can this user toggle feature flags?

These questions don’t reference a specific database record. They’re about who the user is and what capabilities they hold in the abstract. The check is stateless with respect to any particular resource. It only needs to inspect the user.

When there is a target, authorization becomes a function of identity and resource state:

f(user, resource) → bool

Can this user edit this post?
Can this user approve this transaction?
Can this user view this account’s balance?

Now the check requires two things: knowledge about the user and knowledge about the specific resource in question. The post’s authorship matters. The transaction’s status matters. The account’s ownership matters. The authorization logic can’t evaluate without both inputs.

Complexity isn’t the differentiator here. A resource-aware check can be a single line

return $user->is($post->author)

and an identity-only check can involve elaborate role hierarchies. The difference is structural: how many inputs the function requires, and what that implies for how the logic should be organized.


Why the Shape Dictates the Structure

Once you see that resource-aware checks have two inputs instead of one, several consequences follow naturally. They’re also the exact reasons Policies exist as a distinct concept from Gates.

Related checks share context. If you have a view check, an update check, and a delete check for the same model, all three need access to the same resource state. They share the same input shape. Grouping them in a single class (a Policy) reflects the structural reality that these functions operate on the same data, in the same way you’d co-locate related methods on any well-designed service class.

Grouping enables discovery. When all Post authorization logic lives in PostPolicy, the answer to “where are the permission rules for Posts?” is always one file. In a fintech application with forty models and hundreds of authorization rules, the difference between “check PostPolicy” and “search the entire AuthServiceProvider for closures that mention Post” is the difference between a thirty-second lookup and a fifteen-minute archaeology expedition.

Grouping enables framework integration. Because Laravel knows that a Policy maps to a model, it can auto-discover policies by naming convention, auto-wire resource authorization in controllers via authorizeResource(), and pass the model instance directly from route model binding into the policy method. None of this is possible with standalone closures, because closures don’t declare their relationship to a model type.

Isolation enables testing. A Policy is a plain PHP class. Instantiate it, call a method, assert a result. No HTTP request, no service container, no application bootstrap. Gate closures live inside a service provider’s boot() method, which makes them harder to test in isolation. In authorization, where a missed edge case can mean data leaks or compliance violations, testing friction is a risk factor worth eliminating.


What Gates Are (And Why They’re Exactly Right for What They Do)

Gates are named, globally-registered closures that answer identity-only authorization questions. They live in a service provider, they receive a user, and they return a boolean.

use Illuminate\Support\Facades\Gate;

public function boot(): void
{
    // Simple admin check — no model involved
    Gate::define('access-admin', function (User $user): bool {
        return $user->is_admin;
    });

    // Gate with an extra argument
    Gate::define('publish-to-section', function (User $user, Section $section): bool {
        return $user->hasPermission('publish', $section);
    });

    // Gate defined via invokable class (cleaner for complex logic)
    Gate::define('manage-billing', ManageBillingGate::class);
}

These checks don’t need grouping because they don’t share context. “Can this user access admin?” and “Can this user manage billing?” are independent questions. There’s no structural benefit to putting them in a class together. They don’t operate on a shared resource, they don’t need constructor-injected dependencies, and they don’t map to a model that Laravel could auto-discover.

Gates match the shape of the problem: a standalone function with one input and one output.

public function dashboard(): Response
{
    Gate::authorize('access-admin');
    return view('admin.dashboard');
}

You can also call Gates through the user model ($user->can('access-admin')) because Laravel’s Authorizable trait delegates to the Gate layer. Different syntax, identical mechanism.


What Policies Are (And Why the Grouping Isn’t Optional)

Policies are classes that group all authorization logic for a specific Eloquent model. The grouping isn’t a style choice. It’s a structural reflection of the fact that authorization checks for the same resource type share inputs, share context, and benefit from co-location.

php artisan make:policy PostPolicy --model=Post
class PostPolicy
{
    public function before(User $user, string $ability): bool|null
    {
        if ($user->is_super_admin) {
            return true;
        }
        return null;
    }

    public function viewAny(User $user): bool
    {
        return true;
    }

    public function view(User $user, Post $post): bool
    {
        return $post->published || $user->is($post->author);
    }

    public function create(User $user): bool
    {
        return $user->hasRole('editor');
    }

    public function update(User $user, Post $post): bool
    {
        return $user->is($post->author) || $user->hasRole('admin');
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->hasRole('admin');
    }
}

Every method answers a different question about the same resource type. The shared context is what makes the class cohesive. It’s not a grab bag of unrelated checks. It’s the single source of truth for what users can do with Posts.

Rory Sutherland tells a story about hotel minibars. Hotels used to stock them with everything: beer, wine, spirits, chocolate, nuts, water. An efficiency consultant trimmed the selection to cut costs. Revenue collapsed. Guests didn’t want every item, but the completeness itself was the signal. A full minibar says “whatever you need, it’s here.” A sparse one says “figure it out yourself.”

A well-built Policy sends the same signal to every developer who opens it. The complete authorization surface for this model is right here. No searching required.

Resource Controller Integration

This is where the structural investment becomes concrete:

class PostController extends Controller
{
    public function __construct()
    {
        $this->authorizeResource(Post::class, 'post');
    }

    public function update(Request $request, Post $post): Response
    {
        $post->update($request->validated());
        return back();
    }
}

One line in the constructor and every CRUD action in this controller is authorized. No repeated Gate::authorize() calls. No risk of forgetting to add authorization to a new method. The framework handles the mapping because the Policy declared its relationship to the model.

In a regulated environment (fintech, healthcare, anything with audit requirements), this kind of coverage-by-default is the difference between a clean compliance review and a finding. The most dangerous authorization bugs aren’t the ones where the check returns the wrong answer. They’re the ones where the check was never written. authorizeResource() eliminates that entire category of risk.


Auto-Discovery and Registration

Since Laravel 8, Policies are auto-discovered by naming convention: Post maps to PostPolicy in the App\Policies namespace. If you follow that convention, there’s nothing to register.

For non-standard naming or custom directory structures, register explicitly:

protected $policies = [
    Post::class  => PostPolicy::class,
    Order::class => OrderManagementPolicy::class,
];

One detail worth noting: if you organize Policies into subdirectories like Policies/Blog/PostPolicy, register a custom discovery callback via Gate::guessPolicyNamesUsing(). Without it, Laravel won’t find the Policy, and every authorization check will silently return false. These silent 403 errors look correct in every other respect. They’re easy to miss in testing and expensive to debug in production. Set up the callback from the start if you’re using subdirectories.


The Shared API

Gates and Policies expose the same authorization interface. This is a deliberate design decision, and a good one. You can start with a Gate and promote it to a Policy later without changing a single call site:

// All of these work identically for both Gates and Policies:

$this->authorize('update', $post);        // controller helper
Gate::allows('update', $post);            // facade
$user->can('update', $post);              // user model

In Blade:

@can('update', $post)@cannot('delete', $post)@canany(['update', 'delete'], $post)

The interchangeability is intentional. Your understanding of your own authorization needs evolves as the application grows. A check that starts as an identity-only Gate may eventually need resource context as the domain gets more nuanced. The shared interface makes that migration frictionless.


Advanced Patterns

Guest access. Policy methods are skipped for unauthenticated users by default. To evaluate guests (for public content, for example), type-hint the user as nullable:

public function view(?User $user, Post $post): bool
{
    return $post->published;
}

Structured denials. Instead of a bare boolean, return a Response for richer feedback. This is particularly valuable in APIs where the client needs to know why a request was denied:

use Illuminate\Auth\Access\Response;

public function delete(User $user, Post $post): Response
{
    return $user->is($post->author)
        ? Response::allow()
        : Response::deny('You can only delete your own posts.');
}

Unit testing. Policies are plain classes. Test them directly, with no HTTP layer, no mocking, and no container:

it('allows authors to update their own posts', function () {
    $author = User::factory()->create();
    $post   = Post::factory()->for($author, 'author')->create();
    $policy = new PostPolicy();

    expect($policy->update($author, $post))->toBeTrue();
});

Fast, isolated, and precise. When this test fails, there’s exactly one place to look.

Custom Policy methods. Policies aren’t limited to the seven CRUD methods that Artisan scaffolds. Add any named method your domain requires:

public function publish(User $user, Post $post): bool
{
    return $user->hasRole('editor') && $post->status === 'draft';
}

Call it like any other authorization check:

$this->authorize('publish', $post)

The Decision, Reduced to One Question

Strip away the inherited assumptions (that Policies are “for complex things,” that Gates are “less proper,” that this is a matter of preference) and the decision reduces to a single structural test:

Does this authorization check require a specific resource to evaluate?

If yes, the check shares context with other checks on that resource type. It belongs in a Policy, where co-location enables discovery, testing, framework integration, and coverage-by-default.

If no, the check is a standalone function of identity. It belongs in a Gate, where simplicity and global access are the right structural fit.

Not a style preference. The shape of the inputs determines the shape of the solution.


The Progression

In practice, most applications evolve through a natural sequence.

In the early stage, you’re prototyping and the domain is still taking shape. Gates are fast to write and easy to change. Start here. There is nothing wrong with closures, and premature structure slows you down when the ground is shifting.

In the growth stage, you notice that multiple Gates reference the same model. Three closures in your AuthServiceProvider all take a Post as their second argument. That’s your signal. Those checks share context, and they belong together. Extract a Policy. The refactor is mechanical. The shared API means no call sites change.

In the maturity stage, your Policies are the definitive authorization layer. authorizeResource() covers your controllers. Policy unit tests run in milliseconds and catch regressions immediately. New developers open a Policy file and see the entire permission surface for a model in one place. The system is discoverable, testable, and auditable.

None of this is prescriptive. The right structure for your authorization layer depends on where you are in the application’s lifecycle. Laravel’s shared API makes it cheap to evolve from one stage to the next.

The best architectural decisions aren’t the ones that feel sophisticated. They’re the ones that match the shape of the problem. Gates and Policies are both excellent tools. The skill is recognizing which shape you’re looking at.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *