DEV Community

CaStIeL SiDdIqUi
CaStIeL SiDdIqUi

Posted on

Multi-Tenancy Made Simple

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;
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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);
}
Enter fullscreen mode Exit fullscreen mode

Instead, explicitly qualify the table name:

// ✅ CORRECT: Fully qualified column
if (!$user->teams()->where('teams.id', $team->id)->exists()) {
    abort(403);
}
Enter fullscreen mode Exit fullscreen mode
  1. 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,
        ],
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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)