Most Laravel test suites I’ve seen have the same blind spot.
They test that a user can edit their own post. They test that the admin dashboard loads for an admin. CI is green, everyone is happy.
What they don’t test is whether a random logged-in user can edit someone else’s post.
And that’s exactly the kind of bug that ends up in a security report.
Authorization feels “done” the moment you write the policy. But a policy nobody calls, a middleware someone removed from a route group during a refactor, or a gate with a flipped condition won’t break a single happy-path test. It just quietly leaves a door open.
In this article I’ll show you how I approach testing authorization in Laravel: policies, gates, and middleware. Nothing fancy, just patterns that keep these tests short, readable, and actually useful.
Last article in this category: https://codecraftdiary.com/2026/09/06/testing-time-in-laravel/
The Example: A Simple Post Policy
Let’s keep it realistic but small. Users write posts. Only the author can update a post. The author or an admin can delete it.
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->is_admin;
}
}PHPAnd the controller uses it:
public function update(Request $request, Post $post)
{
Gate::authorize('update', $post);
$post->update($request->validate([
'title' => ['required', 'string', 'max:255'],
]));
return redirect()->route('posts.show', $post);
}PHPThere are two separate questions here, and you need tests for both:
- Is the policy logic correct?
- Is the policy actually used where it should be?
Most people only test the first one. Some test neither.
Step 1: Unit Testing the Policy
A policy is just a PHP class with methods that return booleans. That makes it one of the easiest things in Laravel to test.
class PostPolicyTest extends TestCase
{
use RefreshDatabase;
public function test_author_can_update_their_post(): void
{
$author = User::factory()->create();
$post = Post::factory()->for($author)->create();
$this->assertTrue($author->can('update', $post));
}
public function test_other_user_cannot_update_the_post(): void
{
$post = Post::factory()->create();
$stranger = User::factory()->create();
$this->assertFalse($stranger->can('update', $post));
}
}PHPNotice I’m using $user->can() instead of calling new PostPolicy() directly. That’s on purpose. can() goes through Laravel’s Gate, so the test also verifies that the policy is actually registered and that any before() hook behaves the way you expect. Calling the class directly would happily pass even if Laravel never found your policy.
Step 2: Cover the Role Matrix with a Data Provider
Once you have more than two roles, writing one test per combination gets old fast. A data provider turns the whole permission matrix into a single readable table:
#[DataProvider('deletePermissions')]
public function test_delete_permissions(string $role, bool $expected): void
{
$post = Post::factory()->create();
$user = match ($role) {
'author' => $post->user,
'admin' => User::factory()->create(['is_admin' => true]),
'stranger' => User::factory()->create(),
};
$this->assertSame($expected, $user->can('delete', $post));
}
public static function deletePermissions(): array
{
return [
'author can delete' => ['author', true],
'admin can delete' => ['admin', true],
'stranger cannot delete' => ['stranger', false],
];
}PHPWhy strings instead of models? Data providers run before the test’s setUp(), so there’s no database yet. Pass a label, build the model inside the test. Small trick, saves a lot of confusion.
When a new role appears, you add one line. When someone changes the policy, the failing case name tells you exactly what broke.
Step 3: Testing Gates
Gates are for permissions that aren’t tied to a specific model. For example, in AppServiceProvider:
Gate::define('view-reports', fn (User $user) => $user->is_admin);PHPTesting it is just as simple. Gate::forUser() lets you check any user without logging them in:
public function test_only_admins_can_view_reports(): void
{
$admin = User::factory()->create(['is_admin' => true]);
$user = User::factory()->create();
$this->assertTrue(Gate::forUser($admin)->allows('view-reports'));
$this->assertTrue(Gate::forUser($user)->denies('view-reports'));
}PHPAlways test both sides. A gate that returns true for everyone passes the admin test perfectly.
Step 4: Feature Tests — Is the Policy Actually Wired In?
This is the part that really matters.
Your policy tests can be perfect, and the app can still be wide open. Someone deletes the Gate::authorize() line while refactoring the controller. All policy tests stay green. Production is now broken in the worst possible way: silently.
The only thing that catches this is a feature test that hits the real endpoint as the wrong user:
public function test_user_cannot_update_someone_elses_post(): void
{
$post = Post::factory()->create(['title' => 'Original title']);
$stranger = User::factory()->create();
$this->actingAs($stranger)
->put(route('posts.update', $post), ['title' => 'Hacked'])
->assertForbidden();
$this->assertSame('Original title', $post->fresh()->title);
}PHPThat last assertion is the one people skip. Don’t. A 403 response doesn’t guarantee nothing happened. If the update ran before the authorization check, you’d get a 403 and a modified post. Always check that the side effect did not happen.
Guests are a separate case, so give them a separate test:
public function test_guest_is_redirected_to_login(): void
{
$post = Post::factory()->create();
$this->put(route('posts.update', $post), ['title' => 'Hacked'])
->assertRedirect(route('login'));
}PHPFor JSON APIs, use assertUnauthorized() (401) for guests and assertForbidden() (403) for logged-in users without permission. Mixing those two up is a classic.
Step 5: Testing Middleware
Say you have a middleware that only lets subscribed users into certain pages:
class EnsureUserIsSubscribed
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->subscribed) {
return redirect()->route('billing');
}
return $next($request);
}
}PHPI like to test middleware on its own using a tiny route defined only for the test. That way the test doesn’t depend on whatever controller happens to use it today:
protected function setUp(): void
{
parent::setUp();
Route::middleware(['web', EnsureUserIsSubscribed::class])
->get('/_test/premium', fn () => 'premium content');
}
public function test_unsubscribed_user_is_redirected_to_billing(): void
{
$user = User::factory()->create(['subscribed' => false]);
$this->actingAs($user)
->get('/_test/premium')
->assertRedirect(route('billing'));
}
public function test_subscribed_user_gets_through(): void
{
$user = User::factory()->create(['subscribed' => true]);
$this->actingAs($user)
->get('/_test/premium')
->assertOk()
->assertSee('premium content');
}PHPThen add at least one feature test on a real protected route. The isolated test proves the middleware works. The real-route test proves it’s still attached.
One warning: be careful with withoutMiddleware(). It’s tempting to use it everywhere to make tests “simpler”, but it disables auth too. If half your suite runs without middleware, half your suite never tests authorization at all.
Bonus: 403 or 404?
Sometimes you don’t want to tell a stranger that a resource even exists. If you scope the query to the current user, they get a 404 instead of a 403:
$post = $request->user()->posts()->findOrFail($id);PHPBoth approaches are fine. What’s not fine is not knowing which one your app uses. Pick one, and pin it with a test (assertNotFound() or assertForbidden()), so a future refactor can’t change the behavior without anyone noticing.
Quick Checklist
Before I consider an endpoint covered, I go through this list:
- At least one test where the wrong user tries the action and gets denied
- The database is unchanged after a denied request
- Guests are tested separately from logged-in users
- Policy unit tests cover the logic; feature tests cover the wiring
withoutMiddleware()is used rarely, and never in authorization tests
Conclusion
Authorization bugs are sneaky because they don’t crash anything. The app works, the tests pass, and the only person who notices is the one who wasn’t supposed to get in.
The good news is that these tests are some of the simplest you’ll ever write. A factory, actingAs(), one request, one assertion that something didn’t happen. Ten minutes of work per endpoint.
So next time you add a policy, don’t stop at “the author can edit”. Write the test for the stranger too. Future you (and your security team) will thank you.

