Adding multi-tenancy to a SaaS product often leads developers down a rabbit hole of complex packages, multi-database setups, and difficult migrations. However, for 90% of SaaS applications, a clean, single-database multi-tenant architecture with workspace switching is all you need.
In this guide, we'll walk through building a lightweight multi-tenant workspace architecture in Laravel 11, Inertia.js, and React 18 without relying on external multi-tenancy packages.
1. Database Isolation with Global Scopes
To ensure users never see data from another workspace, we can use an Eloquent Global Scope inside a reusable trait.
Create app/Traits/BelongsToTeam.php:
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToTeam
{
protected static function bootBelongsToTeam(): void
{
// 1. Automatically scope all SELECT queries to the active workspace
static::addGlobalScope('team', function (Builder $builder) {
if (auth()->check() && auth()->user()->current_team_id) {
// Table qualification is critical to prevent SQL ambiguity
$builder->where($builder->getModel()->getTable() . '.team_id', auth()->user()->current_team_id);
}
});
// 2. Automatically assign current workspace ID on model creation
static::creating(function ($model) {
if (auth()->check() && !$model->team_id && auth()->user()->current_team_id) {
$model->team_id = auth()->user()->current_team_id;
}
});
}
}
- Fixing Pivot Table Ambiguities When validating workspace membership during team switching, writing raw column checks on pivot relationships can trigger SQL errors. Avoid this:
// ❌ WRONG: Causes MySQL column ambiguity
if (!$user->teams()->where('team_id', $team->id)->exists()) {
abort(403);
}
Instead, explicitly qualify the table name:
// ✅ CORRECT: Fully qualified column
if (!$user->teams()->where('teams.id', $team->id)->exists()) {
abort(403);
}
- Sharing Active Workspace State via Inertia.js
To keep workspace selection available everywhere in React without re-fetching on every page reload, share the current workspace globally in
app/Http/Middleware/HandleInertiaRequests.php:
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? array_merge($request->user()->toArray(), [
'current_team' => $request->user()->currentTeam,
'teams' => $request->user()->teams,
]) : null,
],
]);
}
Now, inside your React navigation bar (AuthenticatedLayout.jsx), you can access auth.user.current_team and render a dropdown to switch workspaces seamlessly!
Conclusion & Starter Kit
With just a custom trait, explicit query scoping, and shared Inertia props, you get complete workspace isolation without bringing in bulky third-party dependencies.
🚀 Want to save weeks of development time?
Check out the free base starter kit on GitHub.
Need Multi-Tenant Workspaces, Roles, Email Invitations, and Stripe Subscriptions ready out of the box? Grab the Pro SaaS Starter Kit on Gumroad.
Top comments (0)