← ALL POSTS
ARCHITECTURE JUL 12, 2026·10 MIN READ

Designing a multi-tenant CRM backend in Laravel

How I structured role-based access, scoped queries, and a reporting layer that stayed fast as tenants grew.

rj
Rollie John Jaictin
Senior Software Developer
Abstract glowing grid of interconnected cubes representing isolated data structures

Multi-tenancy sounds scary until you pick a strategy and commit to it. For this CRM I went with a single database, shared schema approach — every tenant-owned row carries a tenant_id, and a global query scope makes sure you never leak one tenant’s data into another’s dashboard. This post walks through the three decisions that mattered most: data isolation, role-based access, and keeping reports fast as the data grew.

Key Takeaways

  • Single database, shared schema is simpler than separate databases and scales to hundreds of tenants
  • Automatic query scoping via Laravel global scopes prevents data leaks; forgetting the scope is impossible
  • Roles modeled around actual work (owner, agent, viewer) reduce support tickets vs. generic permission matrices
  • Pre-aggregate reporting metrics into nightly summary tables; queries drop from seconds to milliseconds

1. Isolation with a global scope

The riskiest bug in a multi-tenant app is the one where tenant A sees tenant B’s data. Rather than trust every developer to remember a where('tenant_id', …) clause, I pushed it down into the model layer with a global scope that reads the current tenant from the request context.

Set the tenant in middleware:

// app/Http/Middleware/SetTenantMiddleware.php
class SetTenantMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $tenantId = auth()->user()->tenant_id;
        app()->instance('tenant_id', $tenantId);

        return $next($request);
    }
}

Apply the scope to models:

// app/Models/Contact.php
class Contact extends Model
{
    protected static function boot()
    {
        parent::boot();

        static::addGlobalScope('tenant', function (Builder $query) {
            $query->where('tenant_id', app('tenant_id'));
        });
    }
}

// Now this is impossible:
Contact::all();  // Returns only tenant's contacts, never all contacts

// And this requires explicit escape:
Contact::withoutGlobalScopes()->get();  // Audited, clearly intentional

Benefits:

  • Every query is scoped automatically — forgetting the clause is no longer possible
  • A single withoutTenant() escape hatch exists for admin tooling, and it’s audited
  • Background jobs re-bind the tenant explicitly, so queues stay isolated too

The best security control is the one a tired developer can’t accidentally skip.

For background jobs, set the tenant explicitly:

// app/Jobs/SendDailyReport.php
class SendDailyReport implements ShouldQueue
{
    protected $tenantId;

    public function __construct($tenantId)
    {
        $this->tenantId = $tenantId;
    }

    public function handle()
    {
        app()->instance('tenant_id', $this->tenantId);

        // Now queries run scoped to this tenant
        $contacts = Contact::count();
    }
}

2. Roles that map to real work

I resisted a generic permissions matrix and instead modeled roles around what people actually do — owner, agent, viewer — then layered fine-grained abilities only where a customer asked for them. Fewer roles meant fewer support tickets.

Define roles:

// database/seeders/RoleSeeder.php
Role::create(['name' => 'owner', 'description' => 'Full access, can manage team']);
Role::create(['name' => 'agent', 'description' => 'Can view and edit contacts, manage own tasks']);
Role::create(['name' => 'viewer', 'description' => 'Read-only access to contacts and reports']);

Define permissions:

// database/seeders/PermissionSeeder.php
Permission::create(['name' => 'contacts.view']);
Permission::create(['name' => 'contacts.create']);
Permission::create(['name' => 'contacts.edit']);
Permission::create(['name' => 'contacts.delete']);
Permission::create(['name' => 'reports.view']);
Permission::create(['name' => 'team.manage']);

// Assign permissions to roles
Role::where('name', 'owner')->first()->givePermissionTo(
    'contacts.view', 'contacts.create', 'contacts.edit', 'contacts.delete',
    'reports.view', 'team.manage'
);

Role::where('name', 'agent')->first()->givePermissionTo(
    'contacts.view', 'contacts.create', 'contacts.edit', 'reports.view'
);

Role::where('name', 'viewer')->first()->givePermissionTo(
    'contacts.view', 'reports.view'
);

Use policies to enforce:

// app/Policies/ContactPolicy.php
class ContactPolicy
{
    public function view(User $user, Contact $contact)
    {
        return $user->hasPermissionTo('contacts.view')
            && $contact->tenant_id === $user->tenant_id;
    }

    public function update(User $user, Contact $contact)
    {
        return $user->hasPermissionTo('contacts.edit')
            && $contact->tenant_id === $user->tenant_id;
    }
}

// Register in AuthServiceProvider
Gate::policy(Contact::class, ContactPolicy::class);

Keeping the policy layer thin:

The trick was to keep authorization logic out of controllers entirely, so the same rules applied whether a request came from the web app, the mobile client, or a webhook:

// app/Http/Controllers/ContactController.php
class ContactController
{
    public function update(Request $request, Contact $contact)
    {
        $this->authorize('update', $contact);  // Policy enforces it

        $contact->update($request->validated());

        return response()->json($contact);
    }
}

// app/Services/ContactService.php (also enforces)
class ContactService
{
    public function updateContact(User $user, Contact $contact, array $data)
    {
        if ($user->cannot('update', $contact)) {
            throw new AuthorizationException('Not authorized');
        }

        $contact->update($data);
    }
}

Policies apply everywhere — same authorization logic whether the call came from HTTP, a job queue, or an API client.

3. Reporting that stays fast

As tenants grew, the reporting queries were the first thing to slow down. Two changes fixed it: pre-aggregating heavy metrics into a nightly summary table, and adding composite indexes keyed on tenant_id first.

Dashboards went from seconds to milliseconds.

Pre-aggregated reporting table:

// database/migrations/create_contact_daily_summaries.php
Schema::create('contact_daily_summaries', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id');
    $table->date('date');
    $table->integer('new_contacts');
    $table->integer('total_contacts');
    $table->integer('contacted_today');
    $table->timestamps();

    $table->unique(['tenant_id', 'date']);
});

Nightly aggregation job:

// app/Jobs/AggregateContactMetrics.php
class AggregateContactMetrics implements ShouldQueue
{
    public function handle()
    {
        $tenants = Tenant::pluck('id');

        foreach ($tenants as $tenantId) {
            app()->instance('tenant_id', $tenantId);

            $summary = ContactDailySummary::updateOrCreate(
                ['tenant_id' => $tenantId, 'date' => today()],
                [
                    'new_contacts' => Contact::whereDate('created_at', today())->count(),
                    'total_contacts' => Contact::count(),
                    'contacted_today' => Contact::whereDate('last_contacted_at', today())->count(),
                ]
            );
        }
    }
}

// Schedule in app/Console/Kernel.php
$schedule->job(new AggregateContactMetrics)
    ->dailyAt('3:00');

Query the summary, not raw data:

// Before: slow (scans millions of rows)
$metrics = Contact::selectRaw('
    COUNT(*) as total,
    SUM(CASE WHEN created_at >= NOW() - INTERVAL 30 DAY THEN 1 ELSE 0 END) as new_30days,
    SUM(CASE WHEN last_contacted_at >= NOW() - INTERVAL 1 DAY THEN 1 ELSE 0 END) as contacted_today
')
    ->whereDate('created_at', '>=', today()->subDays(90))
    ->groupBy('tenant_id')
    ->get();

// After: fast (small pre-computed table)
$metrics = ContactDailySummary::where('date', '>=', today()->subDays(90))
    ->groupBy('tenant_id')
    ->selectRaw('
        SUM(total_contacts) as total,
        SUM(new_contacts) as new_30days,
        SUM(contacted_today) as contacted_today
    ')
    ->get();

Add composite indexes:

// database/migrations/add_reporting_indexes.php
Schema::table('contacts', function (Blueprint $table) {
    $table->index(['tenant_id', 'created_at']);
    $table->index(['tenant_id', 'last_contacted_at']);
    $table->index(['tenant_id', 'status', 'created_at']);
});

Schema::table('contact_daily_summaries', function (Blueprint $table) {
    $table->index(['tenant_id', 'date']);
});

Index on tenant_id first — the filter that narrows down to a single tenant — then on the column you actually filter or sort by.

Caching the expensive rollups:

class DashboardController
{
    public function index()
    {
        $metrics = cache()->remember(
            'dashboard_metrics_' . auth()->user()->tenant_id,
            now()->addHours(1),  // Cache for 1 hour
            function () {
                return ContactDailySummary::where('date', '>=', today()->subDays(30))
                    ->groupBy('tenant_id')
                    ->selectRaw('... aggregations ...')
                    ->get();
            }
        );

        return response()->json($metrics);
    }
}

// Invalidate on write
class Contact extends Model
{
    protected static function boot()
    {
        parent::boot();

        static::saved(function () {
            cache()->forget('dashboard_metrics_' . app('tenant_id'));
        });
    }
}

Cache the expensive rollups, and invalidate them on write — not on a timer.

Takeaways

Multi-tenancy is mostly about making the safe path the default path. Push isolation into the framework via global scopes so data leaks become impossible. Keep your role model small and aligned with actual job titles — fewer roles, fewer support tickets. Treat reporting as its own performance problem: pre-aggregate into nightly summaries, add composite indexes, and cache the rollups. Do that and the system scales without drama.

#Architecture
Discuss this ↗