How I Structure Multi-Tenant SaaS Apps in Laravel Without Losing My Mind
If you've spent any time in the Laravel community lately, you'll know that multi-tenancy is one of those topics that generates endless debate. Single database or separate databases? Middleware or global scopes? Subdomain routing or path-based tenants? Everyone has an opinion, and honestly, most of them are right — for their specific context.
After building more SaaS products than I care to count over the past 25 years, I've landed on an approach that works really well for the kinds of projects I take on: small-to-medium SaaS applications where a solo developer or a small team needs to ship fast, stay sane, and not paint themselves into a corner six months down the line.
Let me walk you through it.
The Core Decision: One Database, Scoped by Team
For most projects, I go with a single database and scope everything by a team_id (or organisation_id — pick your naming convention and stick to it). Separate databases per tenant sounds appealing, but unless you have a genuine compliance requirement or truly massive data isolation needs, the operational overhead will absolutely kill you as a solo developer.
With a single database approach, you get:
- Simple backups and migrations
- Straightforward local development
- Easy querying across tenants when you need admin insights
- Dramatically lower hosting costs early on
The trade-off is that you need to be disciplined. Every query that touches tenant data must be scoped. Miss one, and you've got a data leak. That's where Laravel's global scopes become your best friend.
Global Scopes: The Safety Net You Actually Want
I create a BelongsToTeam trait that I apply to every tenant-scoped model. It does two things: it applies a global scope to automatically filter queries by the current team, and it sets the team_id on model creation.
trait BelongsToTeam
{
protected static function bootBelongsToTeam(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (app()->has('current.team')) {
$builder->where('team_id', app('current.team')->id);
}
});
static::creating(function ($model) {
if (app()->has('current.team') && empty($model->team_id)) {
$model->team_id = app('current.team')->id;
}
});
}
}
The current.team binding gets resolved in middleware early in the request lifecycle. I bind it into the container so it's available everywhere — including jobs, which I'll come back to.
Resolving the Current Team
I have a SetCurrentTeam middleware that runs on every authenticated route. It looks at the authenticated user, grabs their active team (I store this in the session or on the user record depending on whether they can belong to multiple teams), and binds it into the service container.
class SetCurrentTeam
{
public function handle(Request $request, Closure $next): Response
{
$team = $request->user()?->currentTeam;
if ($team) {
app()->instance('current.team', $team);
app()->instance(Team::class, $team);
}
return $next($request);
}
}
This is intentionally simple. I'm not doing subdomain detection here (though you can extend it to do so). The key insight is centralising this resolution in one place so every other part of the application can trust that app('current.team') is reliable.
Livewire and Alpine: Where This Really Shines
One of the reasons I love this setup is how naturally it plays with Livewire v3. Because the team context is resolved in middleware and bound into the container before any Livewire component boots, my components don't need to think about tenancy at all.
A ProjectList Livewire component just does:
public function render()
{
return view('livewire.project-list', [
'projects' => Project::latest()->paginate(20),
]);
}
The global scope handles the rest. No ->where('team_id', auth()->user()->current_team_id) littered everywhere. No chance of forgetting it on a new query. It's just gone.
With Alpine.js handling the lightweight interactivity and Tailwind CSS v4 keeping styles maintainable, I can move incredibly fast on the UI layer without ever worrying about tenant data leaking between components.
Don't Forget Your Jobs
This is where people get caught out. When you dispatch a queued job, there's no HTTP request — which means no middleware, which means no current.team binding. If your job uses models with global scopes, those scopes won't apply.
My solution is a TenantAwareJob trait:
trait TenantAwareJob
{
public int $teamId;
public function withTeam(Team $team): static
{
$this->teamId = $team->id;
return $this;
}
public function handle(): void
{
$team = Team::find($this->teamId);
app()->instance('current.team', $team);
app()->instance(Team::class, $team);
$this->handleWithTeam();
}
}
Any job that needs tenant context implements handleWithTeam() instead of handle(). The team gets re-bound before any logic runs. Clean, predictable, testable.
A Note on Testing
This whole setup is genuinely enjoyable to test. In my test base class I have a actingAsTeamMember() helper that creates a user, a team, assigns the user, and binds the team into the container. From that point, every factory-created model and every query is automatically scoped. My feature tests read almost like plain English.
When to Reach for a Package
I want to be honest here: if your requirements go beyond what I've described — multiple databases, tenant-specific configs, domain-based routing at scale — then reach for something like Tenancy for Laravel or a similar dedicated package. There's no prize for reinventing that wheel.
But for a huge proportion of SaaS products, especially in the early stages when you're validating an idea and need to move quickly, this leaner approach is genuinely sufficient. It keeps your dependencies minimal, your architecture understandable, and your onboarding friction low when you eventually bring in another developer.
Wrapping Up
Multi-tenancy in Laravel doesn't have to be the scary, complex beast it's sometimes made out to be. A global scope, a middleware, a container binding, and a bit of discipline in your jobs layer gets you most of the way there for most projects.
Start simple. Add complexity only when the problem demands it. That's a principle I keep coming back to after all these years, and it's never done me wrong yet.
If you're building a SaaS product and want to chat through your specific requirements, feel free to reach out — I'm always happy to talk architecture over a (virtual) cup of tea.