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()
4. Popular Package Auto-Detection
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
- Log in to your Filament admin
- Navigate to Custom Reports
- Create a simple report
- 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
- Always use tenant-aware queries - Never bypass global scopes without explicit reason
- Validate tenant ownership - In API endpoints, verify user belongs to tenant
- Secure cache isolation - Ensure tenant resolver never returns null unexpectedly
- 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
- Enable tenant awareness early - Before any migrations
- Test thoroughly - Verify isolation at query, cache, and job levels
- Monitor performance - Multi-tenant queries can be slower
- Plan for scaling - Consider tenant-specific databases for large installations
- 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:
- Backup your data - Custom reports will need tenant assignment
- Update configuration - Enable tenant awareness
- Create migration to add tenant foreign keys to existing tables
- Assign existing data to appropriate tenants
- 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.