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')
Tenant-Shared Reports
By default, reports are owned by their creator and only visible to that user (plus anyone they explicitly share with via the per-user pivot). For teams that want every report to be visible to every member of a tenant, Data Lens ships an opt-in tenant-sharing layer that lives on top of the multi-tenancy you configured above.
Both modes can be enabled at the same time — visibility combines additively.
Enable
Two independent feature flags under features.sharing in config/data-lens.php:
'features' => [
'sharing' => [
// Existing per-user sharing — unaffected
'user_sharing_enabled' => true,
// New: expose a per-report "Share with my team" toggle
'tenant_sharing_enabled' => true,
// New: every newly created report is shared with the tenant
'auto_share_with_tenant' => false,
],
],
Both flags require data-lens.tenant_aware => true (set in Step 1 above). Setting either flag without tenancy enabled logs a boot-time warning and the flags are ignored.
When auto_share_with_tenant is on:
- The per-report toggle is hidden in the form.
- Every new report is created with shared_with_tenant = true.
- The "newly shared" notification is suppressed (otherwise every report creation would notify every tenant member).
- Existing reports are not retroactively shared. See Backfill below.
What gets shared
When a report is tenant-shared:
- Every user in the same tenant sees it in their report list.
- Every user in the tenant can run it, export it, and (subject to your policy) schedule it.
- Only the creator can edit or delete it by default. This is enforced by the default policy and can be overridden.
Permissions
Data Lens ships with Padmission\DataLens\Policies\DefaultCustomReportPolicy, registered automatically only if your application has not already registered a policy for CustomReport. The default rules:
| Action | Default rule | |--------|--------------| | viewAny, create | Always allowed | | view | Creator, per-user pivot member, or tenant member of a shared report | | update, delete, share, schedule | Creator only |
To grant edit or delete to admins or other roles, publish your own CustomReportPolicy:
namespace App\Policies;
use App\Models\User;
use Padmission\DataLens\Models\CustomReport;
class CustomReportPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, CustomReport $report): bool
{
return $report->isAccessibleBy($user);
}
public function create(User $user): bool
{
return true;
}
public function update(User $user, CustomReport $report): bool
{
if ($user->getKey() === $report->creator_id) {
return true;
}
return $user->hasPermissionTo('data-lens.reports.edit-any');
}
public function delete(User $user, CustomReport $report): bool
{
return $user->hasPermissionTo('data-lens.reports.delete-any');
}
public function share(User $user, CustomReport $report): bool
{
return $this->update($user, $report);
}
public function schedule(User $user, CustomReport $report): bool
{
return $this->update($user, $report);
}
}
Register it in your AuthServiceProvider:
protected $policies = [
\Padmission\DataLens\Models\CustomReport::class => \App\Policies\CustomReportPolicy::class,
];
The example above uses spatie/laravel-permission; any permission system works the same way.
Notifications
When a user manually flips the "Share with my team" toggle on a report, every other tenant member is notified. Default channels: database on, email off.
Configurable under features.sharing.notifications:
'notifications' => [
// ... existing user-sharing keys
'tenant_database' => true,
'tenant_email' => false,
'tenant_notification_class' => \Padmission\DataLens\Notifications\ReportSharedWithTenantNotification::class,
],
If both tenant_database and tenant_email are disabled, the observer short-circuits and never dispatches the queued notification job. Replace tenant_notification_class to customize subject, body, or delivery — your class receives the CustomReport as a constructor argument.
Backfill
When you first enable auto_share_with_tenant, existing reports remain user-private until someone flips the toggle. To share every existing report in a tenant in one shot:
\Padmission\DataLens\Models\CustomReport::query()
->where('tenant_id', $tenantId)
->update(['shared_with_tenant' => true]);
Upgrade notes
Two schema changes ship with this feature:
- New column: custom_reports.shared_with_tenant (nullable boolean, default null).
- creator_id is now nullable so reports survive author offboarding. The previous behavior cascaded deletes through creator_id.
After migrating, review any code that accesses $report->creator->name for null safety. We recommend nulling creator_id in your User model's deleting event:
namespace App\Models;
protected static function booted(): void
{
static::deleting(function (User $user): void {
\Padmission\DataLens\Models\CustomReport::query()
->where('creator_id', $user->getKey())
->update(['creator_id' => null]);
});
}
A report with a null creator_id is still accessible to every tenant member if shared_with_tenant = true. If it is not shared, only an admin (via your policy override) can act on it.
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.