Authorization in Blade and APIs

Hiding a control with @can, protecting the route behind it on the server, and choosing between 403 and 404 for a private record.

Syntax@can('update', $post) ... @elsecan('create', App\Models\Post::class) ... @endcan

A template should not offer a button the user may not press. Blade's @can does that. It is not security on its own.

@can in a template

@can('update', $post) compiles to if (app(\Illuminate\Contracts\Auth\Access\Gate::class)->check('update', $post)), so it runs the same policy method the controller runs, and returns false for a guest rather than erroring. @cannot compiles to denies and @canany to any. The longhand you can write yourself is @if (Gate::allows('update', $post)), and the two are the same check.

Hide, then protect

Hiding a form removes the link, not the route. The URL it pointed at is still reachable with curl. Repeat the check where the action happens: Gate::authorize in the controller, or the can middleware on the route, ->middleware('can:update,post'), whose shorthand is ->can('update', 'post'). The second argument is the route parameter name, so route model binding supplies the model. Laravel 13 also accepts the attribute #[Authorize('update', 'post')] on a controller method.

403 or 404

A denied check returns 403, which confirms the record exists. For something private that is a leak. Return Response::denyAsNotFound() from the policy, which is denyWithStatus(404) spelled out. Better still, scope the query: $request->user()->posts()->findOrFail($id) never loads another user's row, so 404 is the honest answer. For a request that asks for JSON, the same AuthorizationException renders as a JSON body with the status code instead of an HTML page.

Example

Example · php
<?php
// app/Http/Controllers/PostController.php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\View;

class PostController extends Controller
{
    /**
     * Scoped lookup. Another author's id is a 404 here, not a 403,
     * because the row is never fetched: the response does not admit
     * that the post exists.
     */
    public function edit(Request $request, int $id): View
    {
        $post = $request->user()->posts()->findOrFail($id);

        return view('posts.edit', ['post' => $post]);
    }

    /**
     * Route model binding hands over any post, so the check is explicit.
     * Gate::authorize throws AuthorizationException: an HTML request gets
     * the 403 page, a request with Accept: application/json gets JSON.
     */
    public function update(Request $request, Post $post): RedirectResponse
    {
        Gate::authorize('update', $post);

        $post->update($request->validate([
            'title' => ['required', 'string', 'max:255'],
        ]));

        return redirect()->route('posts.show', $post);
    }

    /**
     * The same rule for an API client. Returning the booleans lets a
     * JavaScript frontend hide its own buttons, which does not replace
     * the server-side check above.
     */
    public function abilities(Request $request, Post $post): JsonResponse
    {
        return response()->json([
            'can' => [
                'update' => $request->user()->can('update', $post),
                'delete' => $request->user()->can('delete', $post),
            ],
        ]);
    }
}

When to use it

  • Showing an Edit link only to the author of a post
  • Stopping a hidden form from being submitted directly with curl
  • Hiding the existence of other customers' records in a URL with sequential ids
  • Telling a JavaScript or mobile client which buttons to render without trusting it

More examples

The template, and what it compiles to

Blade's directives compile to Gate::check, Gate::denies and Gate::any respectively, so they honour policy before methods and Gate::before exactly as the controller does. Pass a class name rather than an instance for abilities with no model, as @elsecan does here. Keep the compiled form in mind when a directive silently renders nothing: a guest, a missing policy or a typo in the ability name all look identical from the template.

Example · php
{{-- resources/views/posts/show.blade.php --}}

<article>
    <h1>{{ $post->title }}</h1>

    {{-- Runs PostPolicy::update for the signed-in user. --}}
    @can('update', $post)
        <a href="{{ route('posts.edit', $post) }}">Edit</a>
    @elsecan('create', App\Models\Post::class)
        <a href="{{ route('posts.create') }}">Write your own</a>
    @endcan

    @cannot('update', $post)
        <p>You are reading someone else's post.</p>
    @endcannot

    @canany(['update', 'delete'], $post)
        <p>You have some control over this post.</p>
    @endcanany
</article>

{{--
    @can('update', $post) compiles to:

    <?php if (app(\Illuminate\Contracts\Auth\Access\Gate::class)->check('update', $post)): ?>

    which is why it is false for a guest instead of calling a method on
    null. The longhand you would write by hand is the same check:

    @if (Gate::allows('update', $post)) ... @endif
--}}

A policy that hides the record instead of refusing it

Use 404 when the existence of the row is itself private, and 403 when the user already knows the row exists and only the action is refused. Sequential ids make this matter: walking /invoices/1 to /invoices/500 and counting 403s against 404s maps out a customer base. The deny message is what reaches the 403 page and the JSON body, so write it for the person who will read it.

Example · php
<?php
// app/Policies/InvoicePolicy.php

declare(strict_types=1);

namespace App\Policies;

use App\Models\Invoice;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): Response
    {
        return $user->id === $invoice->user_id
            ? Response::allow()
            // 404, not 403: a stranger learns nothing about invoice 91.
            // denyAsNotFound() is denyWithStatus(404) with a name.
            : Response::denyAsNotFound();
    }

    public function update(User $user, Invoice $invoice): Response
    {
        if ($user->id !== $invoice->user_id) {
            return Response::denyAsNotFound();
        }

        // Here 403 is right: the user may see it, just not change it.
        return $invoice->paid_at === null
            ? Response::allow()
            : Response::deny('A paid invoice cannot be edited.');
    }
}

The check on the route instead of in the body

Doing it in middleware fails the request before the controller runs, which keeps a resource controller clean when every action maps to one ability. The usual mistake is writing can:update,$post: the middleware string is parsed before the route is matched, so it must name the parameter. Keep Gate::authorize in the body when the decision needs something the route cannot supply, such as a value from the request.

Example · php
<?php
// routes/web.php

declare(strict_types=1);

use App\Http\Controllers\PostController;
use App\Models\Post;
use Illuminate\Support\Facades\Route;

// 'can' is the alias for Illuminate\Auth\Middleware\Authorize.
// The second argument is the ROUTE PARAMETER name, not a variable:
// {post} is resolved by route model binding and passed to the policy.
Route::put('/posts/{post}', [PostController::class, 'update'])
    ->middleware('can:update,post');

// Shorthand for exactly the same middleware:
Route::put('/posts/{post}', [PostController::class, 'update'])
    ->can('update', 'post');

// No model to bind? Pass the class.
Route::post('/posts', [PostController::class, 'store'])
    ->can('create', Post::class);

// app/Http/Controllers/PostController.php, Laravel 13 attribute form:
//
// use Illuminate\Routing\Attributes\Controllers\Authorize;
//
// #[Authorize('update', 'post')]
// public function update(Request $request, Post $post): RedirectResponse
// {
//     // Already authorized by the time this runs.
// }

Discussion

  • Be the first to comment on this lesson.