A member of Team A opens an invoice URL that belongs to Team B. The page loads. The id was a guess, the route model binding found the row, and nothing asked whether this user may see it. The 404 you expected never happened, because a missing row and a forbidden row are different failures. Authorisation is the second one.

Use a policy when the question is about a model: can this user update this invoice? Laravel finds InvoicePolicy from the model, and the controller calls authorize().

Use a gate when there is no model, or the check is a single ability such as access-admin. Gates stay in a service provider and do not need a class per check.

01 A policy for a model

The policy method receives the user and the model. In a multi-tenant app it has to confirm the invoice belongs to the user's current team. A policy that only checks an is_admin flag, and never looks at the invoice, is a gate that has been given a class.

InvoicePolicy.phpPHP
// app/Policies/InvoicePolicy.php
namespace App\Policies;

use App\Models\Invoice;
use App\Models\User;

class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->current_team_id === $invoice->team_id;
    }

    public function update(User $user, Invoice $invoice): bool
    {
        return $this->view($user, $invoice) && $invoice->status !== 'void';
    }
}

Laravel discovers App\Policies\InvoicePolicy for App\Models\Invoice when the names match. You do not have to register that pair. Register it yourself when the policy class lives somewhere else, or when you want the mapping obvious in review.

AppServiceProvider.phpPHP
// app/Providers/AppServiceProvider.php
use App\Models\Invoice;
use App\Policies\InvoicePolicy;
use Illuminate\Support\Facades\Gate;

class AppServiceProvider
{
    public function boot(): void
    {
        Gate::policy(Invoice::class, InvoicePolicy::class);
    }
}

The controller then asks about that model. authorize() throws AuthorizationException when the policy returns false, which Laravel turns into a 403. The rest of the method only runs for someone who may update this invoice.

InvoiceController.phpPHP
// app/Http/Controllers/InvoiceController.php
use App\Http\Requests\UpdateInvoiceRequest;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;

class InvoiceController
{
    public function update(UpdateInvoiceRequest $request, Invoice $invoice): RedirectResponse
    {
        $this->authorize('update', $invoice);

        $invoice->update($request->safe()->only(['number', 'issued_on', 'notes']));

        return back();
    }
}

If the form request already calls the same ability from authorize(), the controller line is a second check of the same rule. One of them is enough. Put it in the form request when every use of that request should be protected. Put it in the controller when the same request class is reused for a path that has its own rule. Do not put it in both and let them drift.

safe()->only([...]) keeps the lines array off the model. The policy is not the place that learns about mass assignment. Form requests is.

Route scoping and the policy do different jobs. The route finds the record. The policy decides if this user may touch it. A missing scope can still load another team's invoice. The policy is what refuses the update. Scoping is what turns the URL into a 404 before the policy runs. Use the scope when the id in the URL should not be addressable at all. Use the policy even when you also scope, because a job, a command, and a second route will not have that scope.

Check yourself · 1 question

Question 1 of 1A user on Team A requests an invoice that belongs to Team B. The route is scoped to the team, and the policy also checks the team id. What does the user get?

02 A gate when there is no model

access-admin is not about one row. It is about the user. A gate is enough, and a policy would have nothing to receive as its second argument.

AppServiceProvider.phpPHP
// app/Providers/AppServiceProvider.php
use App\Models\User;
use Illuminate\Support\Facades\Gate;

class AppServiceProvider
{
    public function boot(): void
    {
        Gate::define('access-admin', function (User $user): bool {
            return $user->is_admin === true;
        });
    }
}

The route middleware can use that ability without a policy class.

web.phpPHP
// routes/web.php
use App\Http\Controllers\AdminDashboardController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'can:access-admin'])->group(function (): void {
    Route::get('/admin', AdminDashboardController::class);
});

In a controller that is not sitting on a model, call the gate by name. authorize() with one argument checks a gate. authorize() with a model checks a policy.

AdminDashboardController.phpPHP
// app/Http/Controllers/AdminDashboardController.php
use Illuminate\View\View;

class AdminDashboardController
{
    public function __invoke(): View
    {
        $this->authorize('access-admin');

        return view('admin.dashboard');
    }
}

A guest has no user. view and update above type-hint User, so a guest is rejected before the method body. If a guest should be allowed to see a published invoice, the policy method has to accept ?User $user and handle null. Do that only for the ability that is public. update should keep requiring a user.

03 Which one you are writing

If the same closure is copied into three controllers, and each copy receives a model, it is a policy method. If you are reaching for a policy and there is nothing to authorise against, it is a gate.

A gate that loads an invoice and compares team ids is a policy that has nowhere to live. The next person will write a second gate for update, then a third for void, and the team check will differ in one of them. A policy keeps those methods in one class, and update can call view instead of repeating the comparison.

Gate::allowIf() and Gate::denyIf() are for a one-off check inside a method, not for a rule you will repeat. The moment a second caller needs it, move it to a policy or a gate with a name. An inline closure in a controller cannot be reused from a job, and a job is where a forgotten check usually ships.

Before and after hooks on a policy (before() returning true allows every ability) are how a support user gets through without a team match. Return true only for the abilities you mean. A before() that returns true for every admin, including update on a void invoice, silently undoes the status check in update(). Return null from before() to fall through to the method.

Check yourself · 1 question

Question 1 of 1You are about to add "can this user void this invoice?". There is already an InvoicePolicy::update(). Where does void belong?

04 Edge cases

A policy returning false and a missing model are different responses. Do not catch ModelNotFoundException and turn it into 403, and do not catch AuthorizationException and turn it into 404, unless you have decided that hiding the row's existence matters more than a clear forbidden page. Pick one for the invoice URL and use it everywhere. This site's scoped routes 404. Unguarded routes 403 via the policy.

authorize('update', Invoice::class) checks the policy against the class, not an instance. That is the form used for create, where there is no row yet. The method then receives only the user. create on an invoice should still know which team the user is creating it on. Pass the team: $this->authorize('create', [Invoice::class, $team]), and add Team $team after the user on create().

A queued job does not have $this->authorize() unless it uses the trait, and it should not trust that the controller checked. The controller check was for that request. Load the invoice and call Gate::forUser($user)->authorize('update', $invoice) if the job acts on a user's behalf. A job that voids overdue invoices on a schedule is not that user. Give it a policy method such as voidAutomatically that does not pretend a person clicked the button, or skip the policy and keep the rule next to the scheduler where it is reviewed. Do not call update from the scheduler and discover that a void invoice cannot be voided by a user, so the scheduler cannot void it either.

05 Testing it

Assert the other team's user is forbidden, and assert the owning team can update. A test that only checks the happy path will not notice the policy comparing the wrong id.

InvoiceAuthorisationTest.phpPHP
// tests/Feature/InvoiceAuthorisationTest.php
use App\Models\Invoice;
use App\Models\Team;
use App\Models\User;

it('refuses an invoice update from another team', function () {
    $owners = Team::factory()->create();
    $others = Team::factory()->create();
    $invoice = Invoice::factory()->for($owners)->create(['status' => 'open']);
    $intruder = User::factory()->create(['current_team_id' => $others->id]);

    $this->actingAs($intruder)
        ->put(route('invoices.update', $invoice), [
            'number' => 'INV-STOLEN',
            'issued_on' => now()->toDateString(),
            'notes' => null,
        ])
        ->assertForbidden();

    expect($invoice->fresh()->number)->not->toBe('INV-STOLEN');
});

Add the mirror test for the owner, and one for a void invoice whose owner is also refused. That is the status !== 'void' line. Without it, the status check is a comment.

assertForbidden() is the unscoped route. A scoped route should assertNotFound() instead. Test the one you actually shipped.

Check yourself · 1 question

Question 1 of 1The scheduler must void invoices that are ninety days overdue. update() on the policy returns false once an invoice is void, and it requires a user on the team. What should the scheduler call?

06 Checklist

  • A question about one invoice is a policy method. A question about the user alone is a gate.
  • Compare the team id in the policy. Do not rely on the route scope as the only check.
  • authorize('update', $invoice) and a form-request authorize() should not drift. Keep the call in one place.
  • before() returning true allows every ability. Return null to fall through.
  • Jobs and schedules need their own ability when they are not acting as the user.
  • Test the other team, the owner, and the void invoice. Assert the row did not change.

07 Further reading