WirePackages
← Articles

Laravel 13: put controller permissions where you can see them

Use Middleware and Authorize attributes with route binding, an existing policy and a dedicated API resource.

WirePackages Editorial 5 min read
Editorial permission diagram showing authentication, policy and resource stages

An edit endpoint often has three separate jobs before it reaches your business logic: identify the signed-in user, resolve the record from the route, and decide whether that user may change it. When those steps are scattered across route files and controller methods, it becomes easy to review the successful path while overlooking the rejected one.

Laravel 13 expands controller attributes, including middleware and authorisation declarations. This tutorial uses them on a small package endpoint in WirePackages. The endpoint returns the identifier and name of a package only to someone who can update that package. That deliberately modest response lets us concentrate on the permission boundary.

Start from an existing policy

An attribute should point to an application rule, not replace it with a second copy. WirePackages already has a listing policy shared by its package, tool and starter-kit models. Its update rule permits the owner or an administrator. The example reuses that rule, which means the regular listing editor and this endpoint agree about who can manage a record.

This is also why checking only that a user is signed in would be insufficient. Authentication answers who is making the request. It does not answer whether they own the particular package named in the URL. Two registered users should not gain access to each other's edit endpoints merely because they both passed the login screen.

The controller requests the update ability because its data belongs to an editing flow. A public package detail page would use a different rule. Choose the ability from what the endpoint enables rather than copying an attribute from the nearest existing controller.

Declare the boundary on the controller

Here is the complete controller used in the tests. The class-level middleware requires authentication. The method-level authorisation attribute checks the bound package against the existing policy before the method returns its response.

<?php

declare(strict_types=1);

namespace Examples\Laravel13;

use App\Models\Package;
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;

#[Middleware('auth')]
final class ViewPackageController
{
    #[Authorize('update', 'package')]
    public function __invoke(Package $package): PackageResource
    {
        return new PackageResource($package);
    }
}

The package argument in the attribute is the route parameter name. It is not a literal package identifier and it is not the model's table name. That relationship between the route, the method argument and the attribute is worth keeping obvious. Renaming one while leaving the others untouched can change what the authorisation middleware receives.

The method is fully typed and returns a dedicated API resource. It does not fetch the model again, because route model binding has already resolved it. The resource keeps the response shape explicit instead of serializing the entire model. Even an authorised editing endpoint should return the fields its caller actually needs, particularly when models later acquire internal metadata.

<?php

declare(strict_types=1);

namespace Examples\Laravel13;

use App\Models\Package;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/** @mixin Package */
final class PackageResource extends JsonResource
{
    /** @return array{id: int, name: string} */
    public function toArray(Request $request): array
    {
        return ['id' => $this->id, 'name' => $this->name];
    }
}

Laravel wraps this resource in its usual data envelope. The test checks that complete response, so an accidental extra field or a changed response shape is visible during review.

Bind the route before checking the record

The example route explicitly uses the web middleware group. This makes it suitable for the application's session authentication and ensures route binding runs as part of the request pipeline. In an existing web route group, you would not need to add the same group again.

<?php

use Examples\Laravel13\ViewPackageController;
use Illuminate\Support\Facades\Route;

Route::middleware('web')->get('/editorial-example/packages/{package}', ViewPackageController::class);

The example uses a numeric identifier and a package parameter that matches the controller. WirePackages' public directory uses slugs, but this route is an isolated editorial demonstration. The important property is that Laravel resolves an actual Package instance before the record-specific policy check.

If you move the pattern to an API, choose its authentication middleware deliberately. A session-based browser request and a token-based client do not share identical setup. The attribute syntax does not remove the need to configure the guard, bindings and surrounding middleware appropriate to that client.

Test the requests people should not make

The feature test begins with an unauthenticated JSON request and expects an unauthorised response. It then signs in as a different user and expects a forbidden response. Those cases must remain distinct: one caller has no established identity, while the other has an identity without the required permission.

Next, the test signs in as the package owner and checks the exact JSON response. It also requests an identifier that does not exist and expects a not-found response. A passing owner request alone would miss an endpoint that accidentally permits every signed-in user. A passing forbidden request alone could hide an endpoint that rejects everyone.

These are full framework requests using the real route binding and listing policy. There is no mocked authorisation result. During development, leaving the example outside the web middleware group exposed exactly the kind of integration problem this test is intended to catch. The corrected route now exercises the same binding machinery the application relies on.

Keep validation and writes in their own places

This endpoint reads a small editing payload, so it does not need input validation. An actual update endpoint would still use a custom form request for fields, limits and allowed values, followed by an action for the write. Adding an authorisation attribute does not make submitted fields trustworthy or remove the need for validation.

Be careful about duplicating policy decisions between an attribute and a form request. There can be good reasons to use both, but the same ownership rule written in two places will eventually drift. Keep the policy authoritative and let each entry point delegate to it. This makes permission changes easier to review and test.

There is also a readability choice. A short declarative boundary at the top of a controller can make its requirements immediately visible. A large stack of conditional attributes can become harder to understand than a straightforward method. Use the feature where it clarifies the request, and keep the underlying policy small enough to read without chasing unrelated concerns.

The useful result is not fewer characters in a route file. It is a permission boundary that names the user requirement, the operation and the record clearly, with tests proving that guests, other users and owners receive the intended responses. That is a practical place to begin with Laravel 13's controller attributes.

Search WirePackages