T
Tenanto
Documentation / Performance

Performance

Updated Jan 25, 2026

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:

Filament Resource Optimizations

ProjectResource

->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

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)

LicenseResource (Admin)

UserResource (Admin)

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:

Tasks table:

Tenants table:

Additional Performance Indexes

Onboarding progress:

Tasks (completed_at):

Cache Configuration

Important: Default cache driver is now redis instead of database.

// config/cache.php
'default' => env('CACHE_STORE', 'redis'),

For production, ensure:

  1. Redis is properly configured
  2. CACHE_STORE=redis in .env
  3. 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

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

  1. Always use modifyQueryUsing() for eager loading relationships
  2. Use withCount() for count-based columns instead of accessors
  3. Cache navigation badges if they require queries
  4. Use state() instead of getStateUsing() for calculated columns when possible

When Adding New Models

  1. Add composite indexes for tenant_id + frequently_queried_column
  2. Consider adding a dedicated relationship for common eager-loading patterns
  3. Use relationLoaded() check in accessors for optimization

When Adding New Background Jobs

  1. Always use chunk() for processing large datasets
  2. Eager load relationships before processing
  3. Set appropriate $timeout and $tries values
  4. 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