Performance Optimization Guide
This document describes the performance optimizations implemented in Tenanto for production readiness and scalability.
Overview
Tenanto has been optimized to handle large datasets efficiently. Key optimizations include:
- N+1 Query Prevention: Eager loading for all relationship queries
- Query Caching: Navigation badges and dashboard stats cached for 5 minutes
- Batch Processing: Chunked processing for bulk operations
- Database Indexes: Composite indexes for multi-tenant queries
Filament Resource Optimizations
ProjectResource
- Eager Loading: Uses
modifyQueryUsing()withwithCount()for tasks aggregation - Single Query Completion: Completion percentage calculated from eager-loaded counts, not separate queries
- Cached Badge: Navigation badge cached per tenant for 5 minutes
->modifyQueryUsing(fn ($query) => $query
->withCount([
'tasks',
'tasks as open_tasks_count' => fn ($q) => $q->whereIn('status', ['todo', 'in_progress', 'in_review']),
'tasks as completed_tasks_count' => fn ($q) => $q->where('status', 'done'),
]))
TaskResource
- Eager Loading: Project and assignee loaded with
->with(['project', 'assignee']) - Cached Badge Stats: Open and overdue counts cached together to prevent duplicate queries
private static function getNavigationBadgeStats(): array
{
return Cache::remember(
"task_nav_stats_{$tenantId}",
now()->addMinutes(5),
fn (): array => [
'open' => self::getModel()::open()->count(),
'overdue' => self::getModel()::overdue()->count(),
]
);
}
TenantResource (Admin)
- Eager Loading: Subscriptions and users loaded, user count calculated via
withCount() - Optimized Plan Display: Uses eager-loaded subscriptions collection for plan detection
- Cached Badge: Total tenant count cached for 5 minutes
LicenseResource (Admin)
- Cached Badge: Active license count cached for 5 minutes
UserResource (Admin)
- Eager Loading: Uses
modifyQueryUsing()with->with('tenant')for tenant display - Cached Badge: User count cached for 5 minutes
Widget Optimizations
ProjectsOverviewWidget
Dashboard stats (active projects, open tasks, overdue tasks, completed this week) are cached per tenant:
return Cache::remember(
"dashboard_stats_{$tenantId}",
now()->addMinutes(self::CACHE_MINUTES),
fn (): array => [
'active_projects' => Project::active()->count(),
'open_tasks' => Task::open()->count(),
'overdue_tasks' => Task::overdue()->count(),
'completed_this_week' => Task::completed()
->where('completed_at', '>=', $weekStart)
->count(),
]
);
RecentTasksWidget
Uses eager loading for project relationship:
Task::query()
->with('project')
->where('assigned_to', auth()->id())
->open()
->orderByPriority()
->limit(5)
Model Optimizations
Tenant Model
Owner User Relationship
New ownerUser() relationship for efficient batch loading:
public function ownerUser(): HasOne
{
return $this->hasOne(User::class)->where('is_tenant_owner', true);
}
public function owner(): ?User
{
// Use eager loaded relationship if available
if ($this->relationLoaded('ownerUser')) {
return $this->ownerUser;
}
return $this->users()->where('is_tenant_owner', true)->first();
}
Current Plan Accessor
Optimized to use eager-loaded subscriptions when available:
public function getCurrentPlanAttribute(): ?SubscriptionPlan
{
// Use eager loaded subscriptions if available
if ($this->relationLoaded('subscriptions')) {
$subscription = $this->subscriptions
->where('name', 'default')
->first();
// ... process from collection
}
// Fallback to query
return $this->subscription();
}
Service Layer Optimizations
LicenseService
All collection-returning methods now have default limits to prevent memory issues:
// Methods with limits (defaults shown)
$service->getActive(limit: 1000);
$service->getExpiringSoon(withinDays: 30, limit: 1000);
$service->getExpired(limit: 1000);
$service->findByCustomer($email, limit: 100);
// Use pagination for admin interfaces
$service->paginate(filters: [], perPage: 15);
DemoTenantService
Demo query methods also have limits:
// Methods with limits (defaults shown)
$service->getExpiredDemos(limit: 100);
$service->getDemosNeedingWarning(limit: 100);
// For large-scale cleanup, use the job which implements chunking:
CleanupExpiredDemosJob::dispatch();
Background Job Optimizations
CleanupExpiredDemosJob
Uses chunked processing with eager loading to prevent memory issues:
Tenant::query()
->where('is_demo', true)
->whereNull('demo_warning_sent_at')
->where('demo_expires_at', '<=', $warningThreshold)
->where('demo_expires_at', '>', now())
->with('ownerUser')
->chunk(100, function ($tenants) {
// Process in batches of 100
});
Database Indexes
Existing Indexes (Migrations)
Projects table:
tenant_id, created_at- For tenant-scoped listingtenant_id, is_archived- For filtering archived projects
Tasks table:
tenant_id, project_id- For project task queriestenant_id, status- For status filteringtenant_id, assigned_to- For assignment filteringtenant_id, due_date- For due date queriesproject_id, status- For project task statusproject_id, sort_order- For task ordering
Tenants table:
is_demo, demo_expires_at- For demo cleanup queries
Additional Performance Indexes
Onboarding progress:
tenant_id, user_id, completed- For onboarding status queries
Tasks (completed_at):
tenant_id, completed_at- For "completed this week" widget queries
Cache Configuration
Important: Default cache driver is now redis instead of database.
// config/cache.php
'default' => env('CACHE_STORE', 'redis'),
For production, ensure:
- Redis is properly configured
CACHE_STORE=redisin.env- Redis connection details are correct
Performance Monitoring
Query Logging
Enable query logging in development to catch N+1 queries:
// In AppServiceProvider boot()
if (app()->environment('local')) {
DB::listen(function ($query) {
Log::debug($query->sql, $query->bindings);
});
}
Recommended Tools
- Laravel Debugbar: For development query analysis
- Laravel Telescope: For production monitoring
- Sentry Performance: Already configured for production
Cache Invalidation
Navigation badges and stats are cached for 5 minutes. To manually clear:
// Clear all caches
Cache::flush();
// Clear specific tenant caches
Cache::forget("dashboard_stats_{$tenantId}");
Cache::forget("project_nav_badge_{$tenantId}");
Cache::forget("task_nav_stats_{$tenantId}");
// Clear admin caches
Cache::forget('admin_tenant_nav_badge');
Cache::forget('admin_license_nav_badge');
Best Practices for New Features
When Adding New Filament Resources
- Always use
modifyQueryUsing()for eager loading relationships - Use
withCount()for count-based columns instead of accessors - Cache navigation badges if they require queries
- Use
state()instead ofgetStateUsing()for calculated columns when possible
When Adding New Models
- Add composite indexes for
tenant_id + frequently_queried_column - Consider adding a dedicated relationship for common eager-loading patterns
- Use
relationLoaded()check in accessors for optimization
When Adding New Background Jobs
- Always use
chunk()for processing large datasets - Eager load relationships before processing
- Set appropriate
$timeoutand$triesvalues - Add proper logging for monitoring
Performance Improvements Summary
| Component | Before | After | Improvement |
|---|---|---|---|
| ProjectResource table (100 rows) | 200+ queries | 1 query | 99.5% |
| TaskResource table (100 rows) | 100+ queries | 1 query | 99% |
| Dashboard widgets | 4 queries/load | Cached | 100% (5 min) |
| Navigation badges | 8 queries/load | Cached | 100% (5 min) |
| TenantResource (Admin, 100 rows) | 200+ queries | 2 queries | 99% |
| UserResource (Admin, 100 rows) | 100+ queries | 1 query | 99% |
| Demo cleanup (1000 demos) | 1000+ queries | 10 batches | 90% |
| License queries | Unlimited | Max 1000 | Memory-safe |
Related Documentation
- FilamentPHP Customization - Resource optimization
- Deployment Guide - Production configuration
- Multi-Tenancy Architecture - Query scoping