Multi-Tenant Setup Guide

Overview

Data Lens provides comprehensive multi-tenant support with automatic data isolation, cache management, and tenant-aware scheduling. This guide walks you through setting up multi-tenancy from scratch with proper configuration order and best practices.

What Multi-Tenancy Provides

  • Data Isolation: Automatic query scoping to current tenant
  • Cache Isolation: Separate cache namespaces per tenant
  • Job Context: Tenant awareness in queued operations
  • Export Security: Reports only contain tenant-specific data
  • Email Scheduling: Tenant context preserved in scheduled reports

Prerequisites Checklist

Before beginning multi-tenant setup:

  • [ ] Plan your tenant model - Decide what represents a "tenant" (Team, Organization, Company)
  • [ ] Choose tenant key name - Default is tenant_id, but you can customize (e.g., team_id, company_id)
  • [ ] Database not migrated yet - Multi-tenant config must be enabled BEFORE running migrations
  • [ ] Understand resolution strategy - How will Data Lens identify the current tenant?

Step-by-Step Setup

Step 1: Configure Tenant Awareness (BEFORE Migrations)

⚠️ CRITICAL: This must be done before running php artisan migrate

Publish and edit the Data Lens configuration:

php artisan vendor:publish --tag="data-lens-config"

Edit config/data-lens.php:

'tenant_aware' => true, // Enable multi-tenant mode

'tenant_context' => [
    'key' => 'team_id', // Context key for queued jobs
],

'column_names' => [
    'tenant_foreign_key' => 'team_id', // Database column name
],

Why this order matters: When tenant_aware = true, migrations automatically add the tenant foreign key to all Data Lens tables.

Step 2: Set Up Your Tenant Model

Your tenant model should implement the HasCurrentTenantLabel interface if using Filament's tenancy:

<?php

namespace App\Models;

use Filament\Models\Contracts\HasCurrentTenantLabel;
use Illuminate\Database\Eloquent\Model;

class Team extends Model implements HasCurrentTenantLabel
{
    public function getCurrentTenantLabel(): string
    {
        return $this->name;
    }
    
    // Add any tenant-specific methods
    public function timezone(): string
    {
        return $this->attributes['timezone'] ?? config('app.timezone');
    }
}

Step 3: Configure Tenant Model in Data Lens

Register your tenant model in AppServiceProvider:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Padmission\DataLens\DataLens;
use App\Models\Team;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Register tenant model with Data Lens
        DataLens::useTenantModel(Team::class);
        
        // Optional: Custom timezone resolver
        DataLens::setTimezoneResolver(function () {
            $tenant = filament()->getTenant(); // or tenant() helper
            return $tenant?->timezone() ?? config('app.timezone');
        });
    }
}

Step 4: Run Migrations

Now it's safe to run migrations with tenant support:

# Publish migrations first if not using installer
php artisan vendor:publish --tag="data-lens-migrations"

# Run migrations - will include tenant foreign keys
php artisan migrate

Database tables created:

  • custom_reports (with team_id foreign key)
  • custom_report_schedules (with team_id foreign key)
  • custom_report_schedule_history (with team_id foreign key)
  • custom_report_schedule_recipients (with team_id foreign key)
  • custom_report_user (with team_id foreign key)

Step 5: Configure Your Application Models for Tenancy

Ensure your application models are tenant-aware. If using the demo pattern:

<?php

namespace App\Models\Shop;

use App\Models\Concerns\BelongsToTeam;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use BelongsToTeam; // Applies global scope for automatic tenant filtering
    
    // Your model implementation
}

The BelongsToTeam trait typically adds:

trait BelongsToTeam
{
    protected static function bootBelongsToTeam(): void
    {
        static::addGlobalScope(new TeamScope);
    }
    
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }
}

Tenant Resolution Strategies

Data Lens resolves the current tenant using multiple strategies in priority order:

1. Custom Resolver (Highest Priority)

For complex tenant resolution logic:

// config/data-lens.php
'cache' => [
    'tenant_resolver' => function () {
        // Custom logic examples:
        
        // From authenticated user's company
        return auth()->user()?->company_id;
        
        // From route parameter
        return request()->route('tenant_id');
        
        // From session
        return session('current_tenant_id');
        
        // From subdomain
        $host = request()->getHost();
        if (preg_match('/^([^.]+)\./', $host, $matches)) {
            return Tenant::where('subdomain', $matches[1])->value('id');
        }
        
        return null;
    },
],

2. Laravel Context (Queued Jobs)

Automatically handled for background jobs:

// In your job
use Illuminate\Support\Facades\Context;

$tenantId = Context::get('team_id'); // Matches your tenant_context.key

3. Filament Tenant System

If using Filament's built-in tenancy:

// Automatically resolved from:
filament()->getTenant()?->getKey()

Automatically detects:

  • Spatie Laravel Multitenancy: tenant()
  • Tenancy for Laravel: tenant('id')

Testing Your Setup

Test 1: Verify Database Schema

Check that tenant foreign keys were added:

-- Should show team_id column
DESCRIBE custom_reports;
DESCRIBE custom_report_schedules;

Test 2: Test Tenant Resolution

Create a simple test command or tinker session:

// In php artisan tinker
use Padmission\DataLens\Services\TenantResolver;

// Test current tenant resolution
$resolver = app(TenantResolver::class);
$tenantId = $resolver->getCurrentTenantId();
dd($tenantId); // Should return your expected tenant ID

Test 3: Cache Isolation

Verify cache keys include tenant:

// Enable cache and check keys
$cacheKey = app(\Padmission\DataLens\Services\CacheManager::class)
    ->getCacheKey('model_fields', 'App\\Models\\User');

echo $cacheKey; // Should be: data_lens:model_fields:tenant_123:User

Test 4: Create Test Report

  1. Log in to your Filament admin
  2. Navigate to Custom Reports
  3. Create a simple report
  4. Check database: SELECT * FROM custom_reports should show your tenant ID

Troubleshooting

Issue: Migration Failed - Tenant Column Missing

Problem: Ran migrations before enabling tenant_aware = true

Solution:

# Rollback Data Lens migrations
php artisan migrate:rollback

# Enable tenant_aware = true in config
# Run migrations again
php artisan migrate

Issue: Reports Show All Data (No Tenant Isolation)

Problem: Tenant resolver returning null

Debug steps:

// 1. Check tenant resolution
$resolver = app(\Padmission\DataLens\Services\TenantResolver::class);
dd($resolver->getCurrentTenantId()); // Should not be null

// 2. Check if user is properly authenticated with tenant context
dd(auth()->user(), filament()->getTenant());

// 3. Verify your models have tenant scoping
dd(\App\Models\Shop\Product::withoutGlobalScopes()->count()); // All records
dd(\App\Models\Shop\Product::count()); // Tenant-scoped records

Issue: Cache Not Isolated

Problem: Cache keys don't include tenant prefix

Solution: Verify tenant resolver in cache config:

// config/data-lens.php
'cache' => [
    'tenant_resolver' => function () {
        return auth()->user()?->team_id; // Must return actual tenant ID
    },
],

Issue: Scheduled Reports Missing Tenant Context

Problem: Background jobs don't have tenant context

Debug:

// Check if Context is set in job
use Illuminate\Support\Facades\Context;

// In your job or scheduled task
$tenantId = Context::get('team_id'); // Should match tenant_context.key
if (!$tenantId) {
    \Log::error('Tenant context missing in scheduled job');
}

Issue: Foreign Key Constraint Errors

Problem: Models not properly configured with tenant relationships

Solution: Ensure all your models have tenant foreign keys and relationships:

// Migration for your models
Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained()->cascadeOnDelete();
    // other columns
});

// Model
class Product extends Model
{
    use BelongsToTeam;
    
    protected $fillable = ['team_id', /* other fields */];
}

Advanced Configuration

Custom Tenant Model Registration

// Register custom tenant model
DataLens::useTenantModel(\App\Models\Organization::class);

Performance Optimization for Large Tenants

// config/data-lens.php
'cache' => [
    'ttl' => [
        'model_relationships' => 43200, // 12 hours for stable tenant data
    ],
],

'through_relationships' => [
    'optimize_queries' => true, // Enable query optimization
    'auto_index_suggestion' => true, // Log suggested indexes
],

Custom Email Headers for Tenant Context

class TenantAwareReportEmail extends \Padmission\DataLens\Mail\ReportEmail
{
    public function build()
    {
        $this->withSymfonyMessage(function ($message) {
            $message->getHeaders()->addTextHeader(
                'X-Tenant-ID', 
                $this->schedule->customReport->team_id
            );
        });
        
        return parent::build();
    }
}

// Register in config
'mailable_classes' => [
    'report_email' => TenantAwareReportEmail::class,
],

Debugging Tenant Resolution Chain

Add debugging to understand resolution priority:

// config/data-lens.php
'cache' => [
    'tenant_resolver' => function () {
        $tenantId = null;
        
        // Log each resolution attempt
        if ($customTenant = auth()->user()?->team_id) {
            \Log::debug('Tenant resolved from auth user', ['tenant_id' => $customTenant]);
            $tenantId = $customTenant;
        }
        
        if (!$tenantId && ($contextTenant = \Illuminate\Support\Facades\Context::get('team_id'))) {
            \Log::debug('Tenant resolved from context', ['tenant_id' => $contextTenant]);
            $tenantId = $contextTenant;
        }
        
        if (!$tenantId && ($filamentTenant = filament()->getTenant()?->getKey())) {
            \Log::debug('Tenant resolved from Filament', ['tenant_id' => $filamentTenant]);
            $tenantId = $filamentTenant;
        }
        
        \Log::debug('Final tenant resolution', ['tenant_id' => $tenantId]);
        return $tenantId;
    },
],

Security Considerations

Preventing Cross-Tenant Data Access

  1. Always use tenant-aware queries - Never bypass global scopes without explicit reason
  2. Validate tenant ownership - In API endpoints, verify user belongs to tenant
  3. Secure cache isolation - Ensure tenant resolver never returns null unexpectedly
  4. Audit tenant access - Log tenant switches for security monitoring

Testing Multi-Tenant Security

Create tests to verify tenant isolation:

it('prevents cross-tenant data access', function () {
    $tenant1 = Team::factory()->create();
    $tenant2 = Team::factory()->create();
    
    $report1 = CustomReport::factory()->for($tenant1, 'team')->create();
    $report2 = CustomReport::factory()->for($tenant2, 'team')->create();
    
    // Act as tenant1 user
    $user1 = User::factory()->for($tenant1, 'team')->create();
    actingAs($user1);
    
    // Should only see tenant1 reports
    expect(CustomReport::count())->toBe(1);
    expect(CustomReport::first()->id)->toBe($report1->id);
});

Best Practices

  1. Enable tenant awareness early - Before any migrations
  2. Test thoroughly - Verify isolation at query, cache, and job levels
  3. Monitor performance - Multi-tenant queries can be slower
  4. Plan for scaling - Consider tenant-specific databases for large installations
  5. Document your setup - Include tenant model relationships in your project docs

Migration from Non-Tenant Setup

If you need to add multi-tenancy to existing Data Lens installation:

  1. Backup your data - Custom reports will need tenant assignment
  2. Update configuration - Enable tenant awareness
  3. Create migration to add tenant foreign keys to existing tables
  4. Assign existing data to appropriate tenants
  5. Test thoroughly - Ensure no data loss during transition

This completes your multi-tenant setup. Your Data Lens installation now provides secure, isolated reporting for each tenant with automatic query scoping and cache isolation.