Building Web Apps with Laravel Module 8 – Authentication and authorization
Authorization: gates and policies
Write a gate and a policy, call them from controllers, routes and Blade, return 404 where a record is private, and test every ability to catch IDOR.
What you will learn
- Write a gate for an app-wide ability and a policy for a model by naming convention
- Use a policy with Gate::authorize, the can middleware and the @can directive in Blade
- Choose between a 403 and a 404 from denyAsNotFound where even a record's existence is private
- Test every ability with negative cases so an IDOR bug is caught before release
Before you start
What you’ll learn · Before you start
What you will learn
- Write a gate for an app-wide ability and a policy for a model by naming convention
- Use a policy with Gate::authorize, the can middleware and the @can directive in Blade
- Choose between a 403 and a 404 from denyAsNotFound where even a record's existence is private
- Test every ability with negative cases so an IDOR bug is caught before release
Before you start
On this page
A Pathshala guardian may read their own child’s fees but not another family’s; a teacher may edit students at their own centre but not another centre’s; only the owner may delete a record. Authentication proved who the user is, which the earlier lessons covered; authorization decides what that user may do, and Laravel answers it in two shapes.
A authorization gate: A closure, registered with Gate::define, that answers yes or no to an ability such as opening a staff back office; it always receives the user first and may take more arguments, such as a model. is a closure that takes the user and returns yes or no for an app-wide ability, such as opening the staff back office. A authorization policy: A class whose methods each answer one ability for a model, receiving the user and, for abilities about one record, that model instance; Laravel finds it by convention from the model's name. is a class whose methods answer questions about one model: may this user view, update or delete this student? The first two sections build both and print a runnable matrix of who may do what. Later parts add an owner override and the choice between a 403 and a 404. They also close the slip that lets a guardian read another child’s report card by changing a number in the URL.
Gates for app-wide abilities, policies for a model
Reach for a gate when the question has no model behind it. Gate::define('access-admin-area', fn (User $user) => ...) sits in a service provider’s boot method, and Gate::allows('access-admin-area') (or Gate::forUser($user)->allows(...)) answers it anywhere. Each ability is one closure, and Laravel always passes it the user first; a gate may take more arguments, such as a model, but the staff-area question needs none. This is the gate the matrix below asks:
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
}
public function boot(): void
{
// An app-wide ability that is not tied to one model: may this user open
// the staff back office at all? A gate always receives the user first
// (it may take more arguments) and returns a bool. Questions about one
// record ("edit THIS student") go in StudentPolicy instead.
Gate::define('access-admin-area', function (User $user): bool {
return in_array($user->role, ['owner', 'teacher', 'accountant'], true);
});
}
} Reach for a policy when the question is about one record. php artisan make:policy StudentPolicy --model=Student generates a class with a method per ability, and Laravel finds it by convention: App\Policies\StudentPolicy is the policy for App\Models\Student, so no registration is needed. A method such as update receives the user and the model instance, which is exactly what a per-record decision needs. Answer the table’s one question for a check of your own:
Answer the question above: the table then marks what fits your case and says why the rest does not.
| Tool | What it judges | Takes a model | How it is found | Where abilities live |
|---|---|---|---|---|
| Gate How you define it: Gate::define('access-admin-area', fn (User $u) => ...) | Good an app-wide ability, such as opening the back office | Mixed the user first; more arguments, such as a model, are optional | Mixed registered by name in a service provider | Mixed one closure per ability, listed together |
| Policy How you define it: php artisan make:policy StudentPolicy --model=Student | Good actions on one model, such as editing a student | Good receives the user and the model instance | Good found by convention from the model name | Good one method per ability in one class |
How the answers decide
- “Is the check about a specific record (this student, this invoice)?” Yes, there is a model instance: rules out Gate. A per-record question (may this user edit THIS student?) belongs in a policy method that receives the model. A gate can take the model too, but then each such ability is a separate closure that no class groups.
- “Is the check about a specific record (this student, this invoice)?” No, it is a whole-app permission: rules out Policy. An app-wide ability with no model (open the staff area at all) is a gate; a policy needs a model to attach to.
Key idea
A gate answers an app-wide ability in one closure that receives the user; a policy groups the abilities of one model in one class, and a method about one record receives the user and that record. If the check names a specific record, it belongs in a policy.
A policy by convention, and four ways to call it
A policy method answers one ability by comparing the model’s columns against the user asking. StudentPolicy::update returns true only when the user is a teacher whose centre_id equals the student’s; the id in the URL never decides. This is the whole class the matrix asks, one method per ability:
<?php
namespace App\Policies;
use App\Models\Student;
use App\Models\User;
// Discovered by convention: App\Policies\StudentPolicy is the policy for
// App\Models\Student, so $user->can('update', $student) and
// Gate::authorize('update', $student) both land here. Each method answers one
// ability by comparing the student's columns against the user asking.
class StudentPolicy
{
// Only staff see the student list; a guardian has no "list every student".
public function viewAny(User $user): bool
{
return in_array($user->role, ['teacher', 'accountant'], true);
}
// Staff see students of their own centre; a guardian sees only their child.
public function view(User $user, Student $student): bool
{
if (in_array($user->role, ['teacher', 'accountant'], true)) {
return $user->centre_id === $student->centre_id;
}
return $user->role === 'guardian' && $user->id === $student->guardian_id;
}
// A teacher of the student's own centre may edit the record; nobody else.
public function update(User $user, Student $student): bool
{
return $user->role === 'teacher' && $user->centre_id === $student->centre_id;
}
// Deleting a student is reserved for the centre owner, whom this policy does
// not know yet; no ordinary role may delete.
public function delete(User $user, Student $student): bool
{
return false;
}
} The same policy is reached from four places: Gate::authorize('update', $student) in a controller, $user->can('update', $student) in any PHP, the can:update,student middleware on a route, and @can('update', $student) in a Blade view. Older code often calls $this->authorize() instead, a helper that comes from the AuthorizesRequests trait. The base controller of a new Laravel 13 app is an empty class without that trait, so the documented call is the Gate facade. Follow one edit request from the URL to the policy and back:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A request names student 42. The teacher submits PUT /students/42. The route is /students/{student}, so Laravel reads 42 from the URL.
- Binding loads the record. Binding looks up Student 42 by its key and hands the instance to the action. A missing id would already be 404; a found id is loaded whether or not the teacher may touch it.
- The controller asks the policy. Before doing any work the action calls Gate::authorize('update', $student). That resolves StudentPolicy by convention and calls its update method with the teacher and the student.
- The policy compares columns. update returns true only when the teacher's role is teacher and their centre_id equals the student's centre_id. The id in the URL is never the thing that decides.
- Allowed runs; denied is 403. A true lets the controller run and answer 200. A false makes Gate::authorize throw, which Laravel renders as 403. Nothing in the action runs on a denied request.
The runnable matrix seeds three users at centre 1 and three students across two centres, then prints the gate’s answer for each user and the policy’s view, update and delete answers for every student. It is the evidence a plain description cannot give: the exact allow and deny for real rows.
from session import run
# Seeds three users of centre 1 and three students across two centres, then
# prints the app-wide gate's answer for each user and the StudentPolicy's answer
# for every role, ability and student, so the allow/deny matrix is visible on
# real seeded data.
run(
"""
php policy_matrix.php
""",
app=True,
overlay=("policy-probe",),
) Output
$ php policy_matrix.php The gate 'access-admin-area', asked with Gate::forUser($user)->allows(): teacher ALLOW accountant ALLOW guardian DENY StudentPolicy, asked about three students: A = Aarav, centre 1, the guardian's own child B = Diya, centre 1, another family's child C = Imran, centre 2, another centre teacher (centre 1) view A ALLOW B ALLOW C DENY update A ALLOW B ALLOW C DENY delete A DENY B DENY C DENY accountant (centre 1) view A ALLOW B ALLOW C DENY update A DENY B DENY C DENY delete A DENY B DENY C DENY guardian (Aarav's parent) view A ALLOW B DENY C DENY update A DENY B DENY C DENY delete A DENY B DENY C DENY
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 policy_matrix_session.py
A teacher may view and edit both centre-1 students and nothing at centre 2; an accountant may view them but never edit; a guardian may view only Aarav, their own child. Deleting is refused to every role, even a teacher at the student’s own centre. The @can directive hides the edit link from a user who may not use it, but hiding a button is a convenience, never the control; the guard in the controller or route is what refuses the request.
{{-- The @can directive asks the same policy a controller would, so a button is
shown only to a user who may use it. The guard in the controller or route
is still required: hiding a button is a convenience, never the control. --}}
@can('update', $student)
<a href="{{ route('students.edit', $student) }}">Edit this student</a>
@endcan
@cannot('update', $student)
<p>You can view this student but not edit them.</p>
@endcannot
{{-- For an app-wide ability with no model, name the gate: --}}
@can('access-admin-area')
<a href="{{ route('admin.dashboard') }}">Staff dashboard</a>
@endcan A form request moves the check next to the rules: its authorize method runs before validation, so a request from the wrong user is a 403 before a single field is examined.
<?php
// A form request can carry the authorization check next to the rules. authorize()
// runs before validation; returning false (or a denied Response) answers 403
// before a single rule is checked, so a request from the wrong user never
// reaches the controller.
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateStudentRequest extends FormRequest
{
public function authorize(): bool
{
// route('student') is the bound model; $this->user() is the signed-in user.
return $this->user()->can('update', $this->route('student'));
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
];
}
} Checkpoint
Check: Gates for abilities, policies for models · A policy by convention and how to call it
A super-admin override, and choosing between a 403 refusal and a 404 that hides a record Pro: opens with a Pro, Premium or Ultimate pass
Two refinements turn the simple matrix into something a real back office needs: a way for the owner to bypass every check, and a choice about how a refusal looks to the person refused.
In this part:
- An owner override that leaves every other role’s checks alone
- A refusal that explains, or one that hides the record
- Making a hidden record answer exactly like a missing one
- Declaring the check with controller attributes
5 runnable examples (NotFoundBodyTest.php, PHP, Python, SuperAdminAndDenyTest.php, app/Policies/StudentPolicy.php, fixed/bootstrap/app.php, routes/web.php) · about 10 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
How route model binding becomes an IDOR, and the negative tests that catch it Pro: opens with a Pro, Premium or Ultimate pass
Route model binding turns the id in a URL into a loaded record before your action runs, whether or not the signed-in user may see that record. A controller that trusts it lets any guardian read any child’s marks by typing a different number, which is an insecure direct object reference (IDOR): Access to a record through a real identifier, such as an id in a URL, that the application loaded but failed to authorize for the current user..
In this part:
- Why binding hands over a card that is not theirs
- Reproducing the leak in a test, then closing it
- Negative tests for every way access leaks
- Four rules to keep for authorization you can test
1 diagram · 2 runnable examples (Python, ReportCardIdorTest.php, app/Policies/StudentPolicy.php, routes/web.php) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
Build the authorization layer on your own Pathshala project:
- Write
StudentPolicywithview,updateanddelete, discovered by convention, and aGate::define('access-admin-area', ...)for the staff area. Prove the matrix with a test that seeds a teacher, an accountant and a guardian. - Wrap the edit link of a student page in
@can('update', $student), then send the update request directly as the accountant and confirm thatGate::authorizein the controller still answers 403. - Move the same check into a form request’s
authorizemethod and confirm that the wrong user gets a 403 before any validation error appears.
Then test yourself with the questions below.
Practice that adapts to you
Practise this lesson
5 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Each of these asks for a decision and its defence, as an interviewer would:
-
Fresher to mid
Pathshala's fee module needs four checks: opening the fees dashboard, viewing one student's instalments, recording a payment against one instalment, and exporting a centre's collections for the day. For each, decide whether it is a gate or a policy method, on which model, and where you would call it. Defend each choice.
-
Mid to senior
When should a Pathshala policy return Response::denyAsNotFound() instead of Response::deny(), and what does a policy's before() method change about the decision? Give a concrete example of each.
-
Mid to senior
Pathshala's report-card page uses route model binding. Explain how that becomes an IDOR, how you fix it, and why a passing happy-path test suite can still ship the bug. Describe the tests you would write.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
When do you use a gate, and when a policy?
A gate is one closure for an app-wide ability with no model behind it, such as opening the staff back office, and it receives the user first. A policy groups the abilities of one model in a class whose methods receive the user and the record. If the check names a specific record, use a policy.
-
Where is a gate defined, and how is it asked?
With Gate::define('access-admin-area', fn (User $user) => ...) in a service provider's boot method, and asked anywhere with Gate::allows('access-admin-area'), or Gate::forUser($user)->allows(...) for another user.
-
How does Laravel find the policy for a model?
By convention: App\Policies\StudentPolicy is the policy for App\Models\Student, so no registration is needed. php artisan make:policy StudentPolicy --model=Student generates it with one method per ability.
-
What should a policy method compare to decide an update?
The record's own columns against the user asking: StudentPolicy::update returns true only for a teacher whose centre_id equals the student's. The id in the URL never decides.
-
From which places can the same policy be called?
Gate::authorize('update', $student) in a controller, $user->can('update', $student) in any PHP, the can:update,student middleware on a route, @can in Blade and a form request's authorize method. A new Laravel 13 base controller lacks the AuthorizesRequests trait, so $this->authorize() needs the trait added.
-
Is hiding the edit button with @can enough to stop an edit?
No. Hiding a button is a convenience; the authorization call in the controller, the route middleware or the form request is what refuses the request. A form request's authorize runs before validation, so the wrong user gets a 403 before any field is checked.
Key takeaways
- A gate is one closure for an app-wide ability that receives the user first; a policy is a class, found by convention from the model name, whose methods receive the user and, for a check on one record, that model instance.
- A policy method decides from the record’s own columns, such as a matching
centre_id, and never from the id in the URL. - Call a policy with
Gate::authorizein a controller,$user->canin any PHP, thecanmiddleware on a route,@canin Blade orcaninside a form request’sauthorizemethod, and every one of them reaches the same class. - Hiding a button with
@canis a convenience; the authorization call in the controller or the route is what refuses the request.
Terms in this lesson
- authorization gate
- A closure, registered with Gate::define, that answers yes or no to an ability such as opening a staff back office; it always receives the user first and may take more arguments, such as a model.
- authorization policy
- A class whose methods each answer one ability for a model, receiving the user and, for abilities about one record, that model instance; Laravel finds it by convention from the model's name.
- insecure direct object reference (IDOR)
- Access to a record through a real identifier, such as an id in a URL, that the application loaded but failed to authorize for the current user.
References
- Authorization (Laravel (laravel.com))
- Controllers (Laravel (laravel.com))
- Routing (route model binding) (Laravel (laravel.com))
- Middleware (sorting middleware) (Laravel (laravel.com))
- Validation (authorizing form requests) (Laravel (laravel.com))
- Error Handling (rendering exceptions) (Laravel (laravel.com))
- Release Notes, Laravel 13 (Laravel (laravel.com))
- Insecure Direct Object Reference Prevention Cheat Sheet (OWASP)
- laravel/laravel (13.x), app/Http/Controllers/Controller.php (Laravel (GitHub))
- Illuminate\Foundation\Auth\Access\AuthorizesRequests (13.x source) (Laravel (github.com/laravel))
- Illuminate\Foundation\Exceptions\Handler (13.x source) (Laravel (github.com/laravel))
Report a problem with this lesson
Kept only in this browser. Your Learn progress