Authorisation in Laravel: Why Policies Beat Gates for Growing SaaS Apps
I'll be honest — for the first few years of building SaaS products in Laravel, I leaned heavily on Gates. They're quick to write, they live in AuthServiceProvider, and when you're moving fast to hit a launch deadline, that convenience feels like a win.
But here's the thing: convenience in week one often turns into confusion in month six. And when you're building something people actually pay for, "confused developer" is not a state you want to be in when a customer emails you asking why they can see data they shouldn't.
So let me walk you through how I think about authorisation in Laravel now — and why Policies have become my default choice for almost everything.
The Problem With Gates as You Grow
Gates are closures. That means they're anonymous, hard to test in isolation, and they all live in one place. When your app is small, AuthServiceProvider is tidy. When your app has grown to cover projects, teams, billing tiers, and resource ownership — it becomes a wall of logic that nobody wants to touch.
I once inherited a codebase where AuthServiceProvider was over 400 lines long. Every new feature had bolted another Gate onto the end. Nobody was sure which ones were still used. Nobody wanted to delete anything in case something broke.
That's not a codebase. That's an anxiety spiral.
What Policies Actually Give You
A Policy is a plain PHP class that groups all the authorisation logic for a specific model. Need to decide who can view, create, update, or delete a Project? That all lives in ProjectPolicy. Clean, discoverable, and — crucially — testable.
Here's a simple example:
class ProjectPolicy
{
public function update(User $user, Project $project): bool
{
return $user->id === $project->owner_id
|| $user->hasTeamAccessTo($project);
}
}
This is a regular method on a regular class. I can write a unit test for it in seconds. I can read it cold and understand exactly what it does. No closures, no anonymous functions hiding important business logic.
And when I need to check authorisation in a controller, Livewire component, or Blade view, the API is identical:
$this->authorize('update', $project);
@can('update', $project)
<button>Edit Project</button>
@endcan
How I Structure Policies in a Real SaaS
In most of my SaaS projects, I end up with policies for every major model: ProjectPolicy, InvoicePolicy, TeamPolicy, SubscriptionPolicy, and so on.
The key habit I've built is registering them explicitly in AuthServiceProvider rather than relying on Laravel's auto-discovery. Yes, auto-discovery is convenient — but explicit registration means I can see at a glance exactly what's covered, and it avoids any surprises when model namespaces change.
protected $policies = [
Project::class => ProjectPolicy::class,
Invoice::class => InvoicePolicy::class,
Team::class => TeamPolicy::class,
];
I also make a point of writing a before method on policies where I need superadmin access:
public function before(User $user, string $ability): bool|null
{
if ($user->isSuperAdmin()) {
return true;
}
return null; // fall through to individual methods
}
Returning null is important here — it tells Laravel to keep evaluating the specific method rather than short-circuiting everything.
Testing Policies Is a Joy
This is honestly one of my favourite parts. Because a Policy is just a class with methods, testing it requires no HTTP requests, no mocking the world, and no bootstrapping a full application.
it('allows the project owner to update their project', function () {
$owner = User::factory()->create();
$project = Project::factory()->for($owner, 'owner')->create();
$policy = new ProjectPolicy();
expect($policy->update($owner, $project))->toBeTrue();
});
it('prevents other users from updating the project', function () {
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$project = Project::factory()->for($owner, 'owner')->create();
$policy = new ProjectPolicy();
expect($policy->update($otherUser, $project))->toBeFalse();
});
Fast, readable, and completely isolated. This is the kind of test coverage that actually gives you confidence when you're refactoring.
When Gates Still Make Sense
I'm not saying Gates are useless — they're genuinely handy for authorisation checks that aren't tied to a specific model. Things like:
- Can this user access the admin panel?
- Is this user allowed to export data?
- Does this user have permission to invite team members?
These are cross-cutting concerns that don't belong on a model. A Gate or a simple can check on the User model is perfectly fine here. The key word is simple. If a Gate is growing beyond a couple of lines, that's usually a sign the logic wants to live somewhere more structured.
A Note on Livewire
If you're building with Livewire v3 — which I am on almost every project now — authorisation works exactly the same way. In your component, $this->authorize('update', $project) works as you'd expect. You can also use @can and @cannot in your Blade views without any extra setup.
One thing I do in Livewire components is authorise early — typically in the mount method — so there's no chance of a component rendering with data a user isn't allowed to see:
public function mount(Project $project): void
{
$this->authorize('view', $project);
$this->project = $project;
}
It's a small habit that's saved me from embarrassing data leaks more than once.
Final Thoughts
Authorisation isn't the most glamorous part of building a SaaS product. Nobody's writing blog posts raving about their Gate definitions. But it's one of those foundations that, if you get it wrong, causes real problems — both for your users and for your sanity as a developer.
Policies give you structure, discoverability, and testability. They scale with your application without turning into a maintenance nightmare. And they fit naturally into the Laravel features you're already using every day.
If you're currently reaching for a Gate out of habit, just pause for a second and ask whether this logic deserves its own policy. Nine times out of ten, the answer is yes — and your future self will thank you for it.