Skill
Community

performance-optimization

Install
1
Install the plugin
$
npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-react

Want just this skill?

Then install: npx claudepluginhub u/[userId]/[slug]

Description

Use when optimizing application performance across Laravel and React. Covers query optimization, caching strategies, eager loading, React rendering optimization, bundle size reduction, and Inertia-specific performance patterns.

Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

Performance Optimization — Laravel + Inertia + React

Comprehensive performance optimization patterns across the full stack: query optimization, caching, React rendering, Inertia-specific patterns, Vite build optimization, and monitoring tools.

1. Query Optimization

N+1 Prevention with Eager Loading

The most common performance issue. Always eager load relationships you will access.

// BAD: N+1 query — 1 query for orders + N queries for each order's user
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->user->name; // Triggers a query per iteration
}

// GOOD: Eager loading — 2 queries total
$orders = Order::with('user')->get();

// Multiple relationships
$orders = Order::with(['user', 'items', 'items.product', 'tags'])->get();

// Eager loading with constraints
$orders = Order::with([
    'items' => fn ($query) => $query->orderBy('price', 'desc'),
    'user:id,name,email', // Select specific columns
])->get();

preventLazyLoading() in AppServiceProvider

Catch N+1 queries during development by throwing an exception whenever a relationship is lazy loaded:

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Only in non-production environments
    Model::preventLazyLoading(! $this->app->isProduction());

    // Optionally log instead of throwing in production
    Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
        logger()->warning("Lazy loading detected: {$model::class}::{$relation}");
    });
}

Select Specific Columns

Avoid loading unnecessary data from the database:

// BAD: Loads all columns
$users = User::all();

// GOOD: Select only needed columns
$users = User::select(['id', 'name', 'email'])->get();

// With relationships — always include foreign keys
$orders = Order::select(['id', 'user_id', 'total', 'status', 'created_at'])
    ->with('user:id,name')
    ->get();

Chunking for Large Datasets

Process large record sets without exceeding memory limits:

// Process in chunks of 200 records
Order::where('status', 'pending')
    ->chunk(200, function ($orders) {
        foreach ($orders as $order) {
            $this->processOrder($order);
        }
    });

// Lazy collection — memory efficient iteration
Order::where('status', 'pending')
    ->lazy()
    ->each(function (Order $order) {
        $this->processOrder($order);
    });

// chunkById for updates (avoids offset issues)
Order::where('status', 'pending')
    ->chunkById(200, function ($orders) {
        $orders->each->markAsProcessing();
    });

Efficient Aggregations

// BAD: Loads all records into memory to count
$count = Order::all()->count();

// GOOD: Database-level aggregation
$count = Order::count();
$total = Order::sum('total');
$avg   = Order::where('status', 'delivered')->avg('total');

// Subquery selects for computed values
$users = User::withCount('orders')
    ->withSum('orders', 'total')
    ->withAvg('orders', 'total')
    ->get();
// Access: $user->orders_count, $user->orders_sum_total, $user->orders_avg_total

Exists Instead of Count

// BAD: Counts all records when you only need to know if any exist
if (Order::where('user_id', $userId)->count() > 0) { /* ... */ }

// GOOD: Stops at first match
if (Order::where('user_id', $userId)->exists()) { /* ... */ }

2. Database Indexing Strategies

Migration Index Patterns

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->index();     // Foreign key index
    $table->string('status')->index();                        // Frequently filtered
    $table->decimal('total', 10, 2);
    $table->timestamp('shipped_at')->nullable()->index();     // Filtered + sorted
    $table->timestamps();

    // Composite index for common queries
    $table->index(['user_id', 'status']);                     // WHERE user_id = ? AND status = ?
    $table->index(['status', 'created_at']);                  // WHERE status = ? ORDER BY created_at
    $table->index(['user_id', 'created_at']);                 // User orders sorted by date
});

Index Guidelines

ScenarioIndex Strategy
Foreign keysAlways index (Laravel does this with constrained())
WHERE clausesIndex columns used in filters
ORDER BYInclude in index after WHERE columns
Composite queriesCreate composite index matching query column order
Low cardinalityAvoid indexing boolean/enum columns alone
Full-text searchUse fullText() index or dedicated search (Meilisearch)

Identifying Missing Indexes

// Use EXPLAIN to analyze slow queries
DB::listen(function ($query) {
    if ($query->time > 100) { // Log queries over 100ms
        logger()->warning('Slow query detected', [
            'sql'      => $query->sql,
            'bindings' => $query->bindings,
            'time_ms'  => $query->time,
        ]);
    }
});

3. Laravel Caching

Config, Route, and View Caching

# Production optimization commands
php artisan config:cache    # Cache config files into single file
php artisan route:cache     # Cache route registrations
php artisan view:cache      # Pre-compile Blade views
php artisan event:cache     # Cache event-listener mappings
php artisan icons:cache     # Cache Blade icons (if using blade-icons)

# Clear caches
php artisan optimize:clear  # Clears all caches at once

Query Result Caching

use Illuminate\Support\Facades\Cache;

class ProductService
{
    public function getFeaturedProducts(): Collection
    {
        return Cache::remember('products:featured', now()->addHour(), function () {
            return Product::where('is_featured', true)
                ->with('category')
                ->orderBy('sort_order')
                ->get();
        });
    }

    public function getCategories(): Collection
    {
        return Cache::remember('categories:all', now()->addDay(), function () {
            return Category::withCount('products')
                ->orderBy('name')
                ->get();
        });
    }

    /**
     * Invalidate cache when data changes.
     */
    public function updateProduct(Product $product, array $data): Product
    {
        $product->update($data);

        Cache::forget('products:featured');
        Cache::forget("products:{$product->id}");

        return $product->fresh();
    }
}

Tagged Cache (Redis/Memcached Only)

// Group related cache entries with tags for bulk invalidation
Cache::tags(['orders', 'user:1'])->put('user:1:orders', $orders, 3600);
Cache::tags(['orders', 'user:2'])->put('user:2:orders', $orders, 3600);

// Invalidate all order caches at once
Cache::tags('orders')->flush();

// Invalidate all caches for a specific user
Cache::tags('user:1')->flush();

Redis Caching Patterns

// config/database.php — Redis connection
'redis' => [
    'cache' => [
        'url'      => env('REDIS_URL'),
        'host'     => env('REDIS_HOST', '127.0.0.1'),
        'port'     => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_CACHE_DB', '1'),
    ],
],

// Usage with Redis-specific features
use Illuminate\Support\Facades\Redis;

// Atomic counter
Redis::incr('orders:count:today');

// Sorted set for leaderboard
Redis::zadd('products:popular', $viewCount, "product:{$id}");
$topProducts = Redis::zrevrange('products:popular', 0, 9);

// Lock for preventing race conditions
$lock = Cache::lock('order:process:' . $orderId, 10);
if ($lock->get()) {
    try {
        $this->processOrder($order);
    } finally {
        $lock->release();
    }
}

4. Queue Processing for Heavy Tasks

Offload slow operations to background queues to keep response times fast.

// Dispatch expensive operations to the queue
class OrderService
{
    public function create(OrderDTO $dto): Order
    {
        $order = DB::transaction(function () use ($dto) {
            return Order::create($dto->toArray());
        });

        // These run in the background — response returns immediately
        GenerateInvoicePdf::dispatch($order);
        SendOrderConfirmation::dispatch($order);
        SyncOrderToExternalSystem::dispatch($order)->onQueue('integrations');
        UpdateAnalytics::dispatch($order)->delay(now()->addMinutes(5));

        return $order;
    }
}

// Job class
class GenerateInvoicePdf implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public readonly Order $order,
    ) {}

    public function handle(PdfGenerator $pdf): void
    {
        $pdf->generate($this->order);
    }

    public int $tries = 3;
    public int $backoff = 60; // Wait 60s between retries
}

5. React Performance

React.memo — Prevent Unnecessary Re-renders

import { memo } from 'react';

// Without memo: re-renders every time parent re-renders
// With memo: only re-renders when props change
const OrderRow = memo(function OrderRow({ order }: { order: Order }) {
    return (
        <tr>
            <td>{order.customer_name}</td>
            <td>{order.formatted_total}</td>
            <td>{order.status}</td>
        </tr>
    );
});

// Custom comparison function for complex props
const OrderCard = memo(
    function OrderCard({ order, onEdit }: Props) {
        return (/* ... */);
    },
    (prevProps, nextProps) => {
        // Return true if props are equal (skip re-render)
        return prevProps.order.id === nextProps.order.id
            && prevProps.order.updated_at === nextProps.order.updated_at;
    }
);

useMemo — Cache Expensive Computations

import { useMemo } from 'react';

function OrderDashboard({ orders }: { orders: Order[] }) {
    // Recomputes only when orders array changes
    const stats = useMemo(() => ({
        totalRevenue: orders.reduce((sum, o) => sum + o.total, 0),
        averageOrder: orders.length > 0
            ? orders.reduce((sum, o) => sum + o.total, 0) / orders.length
            : 0,
        byStatus: orders.reduce((acc, o) => {
            acc[o.status] = (acc[o.status] || 0) + 1;
            return acc;
        }, {} as Record<string, number>),
    }), [orders]);

    // Filtered list — recomputes only when dependencies change
    const pendingOrders = useMemo(
        () => orders.filter(o => o.status === 'pending'),
        [orders]
    );

    return (/* render stats and pendingOrders */);
}

useCallback — Stable Function References

import { useCallback, useState } from 'react';

function OrderList({ orders }: { orders: Order[] }) {
    const [selectedId, setSelectedId] = useState<number | null>(null);

    // Without useCallback: new function on every render,
    // causing all child OrderRow components to re-render
    const handleSelect = useCallback((id: number) => {
        setSelectedId(id);
    }, []); // Empty deps: function never changes

    const handleDelete = useCallback((id: number) => {
        router.delete(route('orders.destroy', id), {
            preserveScroll: true,
        });
    }, []);

    return (
        <table>
            {orders.map(order => (
                <OrderRow
                    key={order.id}
                    order={order}
                    isSelected={order.id === selectedId}
                    onSelect={handleSelect}
                    onDelete={handleDelete}
                />
            ))}
        </table>
    );
}

When to Use Each

HookUse WhenSkip When
React.memoComponent receives same props frequently, rendering is expensiveComponent always receives new props, or rendering is cheap
useMemoExpensive computation, large list filtering/sorting, derived dataSimple values, already fast computations
useCallbackPassing callbacks to memoized children, stable event handlersNot passing to memoized children, simple inline handlers

6. Code Splitting and Lazy Loading

React.lazy with Suspense

import { lazy, Suspense } from 'react';

// Split large components into separate chunks
const AdminDashboard = lazy(() => import('@/Pages/Admin/Dashboard'));
const ReportViewer   = lazy(() => import('@/Components/ReportViewer'));
const RichTextEditor = lazy(() => import('@/Components/RichTextEditor'));

function App() {
    return (
        <Suspense fallback={<LoadingSpinner />}>
            <AdminDashboard />
        </Suspense>
    );
}

// Inline fallback for smaller components
function OrderDetail({ order }: Props) {
    return (
        <div>
            <h1>Order #{order.id}</h1>
            <Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
                <ReportViewer orderId={order.id} />
            </Suspense>
        </div>
    );
}

Inertia Page-Level Code Splitting

Inertia automatically supports code splitting when you use dynamic imports in your resolve function:

// resources/js/app.tsx
createInertiaApp({
    resolve: (name) => {
        // Automatic code splitting — each page becomes its own chunk
        const pages = import.meta.glob('./Pages/**/*.tsx');
        return pages[`./Pages/${name}.tsx`]();
    },
    // ...
});

7. Inertia-Specific Optimizations

Partial Reloads

Only request the data you need on subsequent visits:

import { router } from '@inertiajs/react';

// Only reload the 'orders' prop, not the entire page data
function refreshOrders() {
    router.reload({ only: ['orders'] });
}

// Use with polling for live updates
useEffect(() => {
    const interval = setInterval(() => {
        router.reload({ only: ['notifications'] });
    }, 30000);
    return () => clearInterval(interval);
}, []);

Lazy Props

Props that are expensive to compute can be deferred until explicitly requested:

// Controller — lazy props are not evaluated on initial page load
return Inertia::render('Orders/Show', [
    'order'      => $order,
    'can'        => [...],

    // Lazy: only loaded when explicitly requested via partial reload
    'auditLog'   => Inertia::lazy(fn () =>
        AuditLog::where('order_id', $order->id)->latest()->limit(50)->get()
    ),

    // Deferred: loaded automatically after initial render
    'statistics' => Inertia::defer(fn () =>
        $this->statisticsService->forOrder($order)
    ),
]);
// Frontend: request lazy prop when user clicks
function OrderShow({ order, auditLog, statistics }: Props) {
    const loadAuditLog = () => {
        router.reload({ only: ['auditLog'] });
    };

    return (
        <div>
            <OrderDetails order={order} />

            {/* Statistics load automatically after initial render (deferred) */}
            {statistics ? <StatsPanel data={statistics} /> : <StatsPlaceholder />}

            {/* Audit log loads on demand (lazy) */}
            <button onClick={loadAuditLog}>Load Audit Log</button>
            {auditLog && <AuditLogTable entries={auditLog} />}
        </div>
    );
}

Persistent Layouts

Prevent layout components from unmounting between page visits, preserving state (scroll position, audio players, sidebars):

// resources/js/Layouts/AuthenticatedLayout.tsx
export default function AuthenticatedLayout({ children }: { children: React.ReactNode }) {
    return (
        <div className="min-h-screen">
            <Sidebar />
            <main>{children}</main>
        </div>
    );
}

// resources/js/Pages/Orders/Index.tsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

function OrderIndex({ orders }: Props) {
    return <div>{/* page content */}</div>;
}

// Persistent layout — AuthenticatedLayout is not remounted between pages
OrderIndex.layout = (page: React.ReactNode) => (
    <AuthenticatedLayout>{page}</AuthenticatedLayout>
);

export default OrderIndex;

Deferred Props

Load heavy data after the initial page render for faster perceived load times:

return Inertia::render('Dashboard', [
    'user'       => $user,                                    // Loaded immediately
    'recentOrders' => Inertia::defer(fn () =>                 // Loaded after render
        Order::forUser($user->id)->latest()->limit(5)->get()
    ),
    'analytics'  => Inertia::defer(fn () =>                   // Loaded after render
        $this->analyticsService->getDashboardStats()
    ),
]);

Prefetching Links

import { Link } from '@inertiajs/react';

// Prefetch on hover — starts loading the page data before the user clicks
<Link href={route('orders.show', order.id)} prefetch>
    View Order
</Link>

// Prefetch with caching options
<Link href="/dashboard" prefetch cacheFor="1m">
    Dashboard
</Link>

8. Vite Build Optimization

Tree Shaking

Ensure unused code is eliminated by using named imports:

// BAD: Imports the entire library
import _ from 'lodash';
_.debounce(fn, 300);

// GOOD: Import only what you need (tree-shakeable)
import { debounce } from 'lodash-es';
debounce(fn, 300);

// GOOD: Even more granular
import debounce from 'lodash/debounce';

Chunk Splitting Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [
        laravel({ input: 'resources/js/app.tsx', refresh: true }),
        react(),
    ],
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    // Separate vendor chunks for better caching
                    'vendor-react': ['react', 'react-dom'],
                    'vendor-inertia': ['@inertiajs/react'],
                    'vendor-ui': ['@headlessui/react', '@heroicons/react'],
                },
            },
        },
        chunkSizeWarningLimit: 500, // Warn if chunk exceeds 500kB
    },
});

Analyzing Bundle Size

# Install the visualizer plugin
npm install -D rollup-plugin-visualizer

# Add to vite.config.ts and run build
npx vite build
# Opens a visual treemap of your bundle

9. Image Optimization

Responsive Images

function ProductImage({ product }: { product: Product }) {
    return (
        <img
            src={product.image_url}
            srcSet={`
                ${product.image_sm} 400w,
                ${product.image_md} 800w,
                ${product.image_lg} 1200w
            `}
            sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
            alt={product.name}
            loading="lazy"          // Native lazy loading
            decoding="async"        // Non-blocking decode
            className="w-full h-auto object-cover"
        />
    );
}

Laravel Image Processing (Queue)

class ProcessProductImage implements ShouldQueue
{
    public function handle(): void
    {
        $image = Image::make(Storage::get($this->path));

        // Generate multiple sizes
        foreach ([400, 800, 1200] as $width) {
            $resized = $image->resize($width, null, function ($constraint) {
                $constraint->aspectRatio();
                $constraint->upsize();
            })->encode('webp', 80);

            Storage::put(
                "products/{$this->productId}/{$width}.webp",
                $resized
            );
        }
    }
}

10. API Response Optimization

Pagination

Always paginate collections. Never return unbounded result sets.

// Standard pagination
$orders = Order::with('user')
    ->latest()
    ->paginate(15);                // 15 items per page

// Simple pagination (no total count — faster for large tables)
$orders = Order::with('user')
    ->latest()
    ->simplePaginate(15);          // No "total" count query

// Cursor pagination (most efficient for infinite scroll)
$orders = Order::with('user')
    ->orderBy('id')
    ->cursorPaginate(15);

Sparse Fieldsets

Let clients request only the fields they need:

// Controller
public function index(Request $request)
{
    $query = Order::query();

    // Only load requested fields
    if ($fields = $request->input('fields')) {
        $columns = array_intersect(
            explode(',', $fields),
            ['id', 'customer_name', 'total', 'status', 'created_at']
        );
        $query->select(array_merge($columns, ['user_id'])); // Always include FK
    }

    // Only load requested relationships
    if ($request->boolean('include_items')) {
        $query->with('items:id,order_id,product_id,quantity,price');
    }

    return $query->paginate(15);
}

Conditional Relationship Loading

return Inertia::render('Orders/Index', [
    'orders' => Order::query()
        ->with('user:id,name')
        ->when(request('include') === 'items', fn ($q) => $q->with('items'))
        ->latest()
        ->paginate(15),
]);

11. Monitoring and Profiling Tools

Laravel Telescope

Development-level insight into queries, requests, exceptions, and more:

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
// config/telescope.php — only enable locally
'enabled' => env('TELESCOPE_ENABLED', false),

// app/Providers/TelescopeServiceProvider.php
protected function gate(): void
{
    Gate::define('viewTelescope', function (User $user) {
        return $user->is_admin;
    });
}

Access at /telescope to view queries, slow queries, exceptions, mail, notifications, and cache operations.

Laravel Debugbar

In-browser debug toolbar showing queries, route info, and timing:

composer require barryvdh/laravel-debugbar --dev

Automatically displays at the bottom of every page in local development. Shows:

  • Number of queries and total query time
  • Duplicate queries (N+1 detection)
  • Route and middleware information
  • Memory usage and execution time
  • Cache hits/misses

React DevTools Profiler

Built into React DevTools browser extension:

  1. Open React DevTools in the browser
  2. Switch to the Profiler tab
  3. Click Record and interact with the page
  4. Review the flamegraph to identify slow renders
  5. Look for components re-rendering unnecessarily

Key things to look for:

  • Components re-rendering when their props have not changed (wrap with React.memo)
  • Expensive computations running on every render (wrap with useMemo)
  • New function references causing child re-renders (stabilize with useCallback)

Query Monitoring in Production

// In AppServiceProvider::boot() — production query monitoring
if (! $this->app->isProduction()) {
    return;
}

DB::listen(function ($query) {
    if ($query->time > 500) { // Log queries over 500ms
        Log::channel('slow-queries')->warning('Slow query', [
            'sql'      => $query->sql,
            'bindings' => $query->bindings,
            'time_ms'  => $query->time,
            'url'      => request()->fullUrl(),
        ]);
    }
});

Performance Checklist

AreaCheckTool
QueriesNo N+1 queriespreventLazyLoading(), Debugbar
QueriesProper eager loading on all controllersCode review
QueriesIndexes on filtered/sorted columnsEXPLAIN, Telescope
CachingConfig/route/view cached in productionphp artisan optimize
CachingExpensive queries cached with TTLCache::remember()
QueuesHeavy tasks dispatched to queueTelescope, Horizon
ReactNo unnecessary re-rendersReact DevTools Profiler
ReactLarge components code-split with lazy()Bundle analyzer
InertiaPartial reloads for polling/filtersNetwork tab
InertiaPersistent layouts for stable UIPage transitions
InertiaLazy/deferred props for heavy dataController review
BuildTree shaking enabled, named imports usedBundle analyzer
BuildVendor chunks split for cachingvite.config.ts
ImagesLazy loading, responsive srcSet, WebPLighthouse audit
APIAll collections paginatedCode review
DatabaseComposite indexes for common query patternsEXPLAIN, slow query log
Stats
Stars0
Forks0
Last CommitFeb 8, 2026

Similar Skills