Performance Audit for AI-Built Apps: Finding the Hidden Bottlenecks

calendar_today September 7, 2026
person info@softcrony.com
folder DevOps
Performance audit dashboard for AI-built web application showing N+1 query bottlenecks being identified and optimized in Laravel PHP application

🕮14 min read · 2,789 words

This is Part 5 of our AI Code Audit Series — covering the essential audits every team should run when building with AI agentic editors.

Performance problems in AI-generated code share one characteristic that makes them particularly dangerous: they’re invisible in development. The application works correctly on a developer’s machine with a few hundred test records. Tests pass. The feature ships. Three months later, with real data volumes and real concurrent users, response times climb, queries time out, and users complain that the application is slow.

By that point, the performance problems are embedded across the codebase — in query patterns, missing indexes, absent caching, and architectural decisions that made sense for development-scale data but fail at production scale. Fixing them after the fact is significantly more expensive than finding them before launch.

This post covers the specific performance failure patterns AI agentic editors introduce consistently, how to find them before they affect real users, and how to fix them correctly.

Why AI-Generated Code Has Performance Problems

AI coding tools optimise for correctness on the inputs described in the prompt — not for performance at scale. When you ask an AI to implement a feature, it generates code that works correctly for the use case described. It doesn’t consider what happens when the relevant database table has two million rows instead of fifty test records, or when five hundred users hit the same endpoint simultaneously instead of one developer testing locally.

The AI also doesn’t know your data volumes, your expected concurrent users, your response time requirements, or your infrastructure constraints. It makes reasonable default choices for a generic application — choices that are adequate at small scale and increasingly problematic as the application grows.

Performance problems are also invisible to functional testing. An N+1 query that makes 200 database calls instead of 2 returns the correct data — it just takes forty times longer. A missing database index returns correct results — queries just scan the full table instead of using the index. These problems pass all functional tests and appear only under load profiling or in production monitoring.

Performance Problem 1 — N+1 Query Problems

This is the most common and most impactful performance problem in AI-generated Laravel code. The AI generates code that loads a collection of records and then accesses a relationship on each one inside a loop — producing one query to load the collection and one additional query per record to load the relationship.

// VULNERABLE — N+1 query problem
// AI-generated endpoint — looks correct, performs terribly at scale
public function index(): JsonResponse
{
    $orders = Order::where('status', 'pending')->get();
    // One query: SELECT * FROM orders WHERE status = 'pending'
    // Returns 500 orders

    return OrderResource::collection($orders);
}

// In OrderResource.php — AI-generated
public function toArray(Request $request): array
{
    return [
        'id'           => $this->id,
        'total'        => $this->total,
        'customer'     => $this->user->name,        // Query 2, 3, 4... 501
        'items_count'  => $this->items->count(),    // Query 502, 503... 1001
        'latest_item'  => $this->items->first()->name, // Using already-loaded collection
        'address'      => $this->shippingAddress->full_address, // Query 1002... 1501
    ];
}
// Total: 1 + 500 + 500 + 500 = 1,501 queries for one page request

// CORRECT — eager loading eliminates N+1
public function index(): JsonResponse
{
    $orders = Order::where('status', 'pending')
        ->with([
            'user:id,name,email',           // Only load needed columns
            'items',                         // Load all items in one query
            'shippingAddress:id,order_id,full_address',
        ])
        ->get();
    // Total: 4 queries regardless of how many orders exist

    return OrderResource::collection($orders);
}

// Even better — paginate to limit result set
public function index(): JsonResponse
{
    $orders = Order::where('status', 'pending')
        ->with(['user:id,name', 'items', 'shippingAddress:id,order_id,full_address'])
        ->latest()
        ->paginate(25); // Never load unbounded result sets

    return OrderResource::collection($orders);
}

N+1 problems compound with result set size. On a development database with 50 orders, the difference between 51 queries and 4 queries is imperceptible. On a production database with 10,000 orders, the difference is the application being functional versus completely unusable.

Finding N+1 problems — use Laravel Debugbar or Telescope:

# Install Laravel Debugbar for development
composer require --dev barryvdh/laravel-debugbar

# Install Laravel Telescope for development/staging
composer require --dev laravel/telescope
php artisan telescope:install
php artisan migrate

# Telescope shows all queries per request — look for:
# - Same query repeated multiple times with different ID values
# - Query count that scales with result set size

# Automated detection with Laravel's query log
DB::enableQueryLog();

$orders = Order::where('status', 'pending')->get();
$resource = OrderResource::collection($orders);

$queries = DB::getQueryLog();
$duplicatePatterns = collect($queries)
    ->groupBy(fn($q) => preg_replace('/\d+/', '?', $q['query']))
    ->filter(fn($group) => $group->count() > 5); // Flag query patterns repeating >5 times

Log::warning('Potential N+1 detected', ['patterns' => $duplicatePatterns->keys()]);

Performance Problem 2 — Missing Database Indexes

AI-generated migrations create tables with primary keys but frequently miss indexes on columns used in WHERE clauses, ORDER BY clauses, and foreign keys. On small datasets this is imperceptible. On production datasets it means full table scans for every query.

// AI-generated migration — missing critical indexes
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('status');           // Frequently filtered — no index
    $table->string('order_number');     // Frequently searched — no index  
    $table->timestamp('created_at');    // Frequently sorted — no index
    $table->decimal('total', 10, 2);
    $table->timestamps();
});

// CORRECT — indexes on every column used in WHERE, ORDER BY, GROUP BY
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->index(); // Foreign key needs index
    $table->string('status', 20)->index();                // Filtered frequently
    $table->string('order_number', 50)->unique();         // Searched and must be unique
    $table->decimal('total', 10, 2);
    $table->timestamps();

    // Composite index for common query pattern: user's orders by status
    $table->index(['user_id', 'status']);

    // Composite index for admin dashboard: orders by status, newest first
    $table->index(['status', 'created_at']);
});

// Add missing indexes to existing tables via new migration
Schema::table('orders', function (Blueprint $table) {
    $table->index('status');
    $table->index(['user_id', 'status']);
    $table->index(['status', 'created_at']);
});

Finding missing indexes — query EXPLAIN output:

# Check query execution plan for slow queries
# Run in MySQL/MariaDB directly
EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC;
# Look for: type = 'ALL' (full table scan — needs index)
# Good results: type = 'ref' or 'range' (using index)

# Find queries doing full table scans in Laravel
DB::listen(function ($query) {
    if ($query->time > 100) { // Queries taking more than 100ms
        $explain = DB::select('EXPLAIN ' . $query->sql, $query->bindings);
        $fullScans = collect($explain)->filter(fn($row) => $row->type === 'ALL');
        
        if ($fullScans->isNotEmpty()) {
            Log::warning('Full table scan detected', [
                'sql'  => $query->sql,
                'time' => $query->time,
            ]);
        }
    }
});

# Find foreign keys without indexes
SELECT 
    TABLE_NAME, COLUMN_NAME
FROM 
    INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
WHERE 
    kcu.REFERENCED_TABLE_NAME IS NOT NULL
    AND NOT EXISTS (
        SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS s
        WHERE s.TABLE_SCHEMA = kcu.TABLE_SCHEMA
        AND s.TABLE_NAME = kcu.TABLE_NAME
        AND s.COLUMN_NAME = kcu.COLUMN_NAME
    )
AND kcu.TABLE_SCHEMA = 'your_database_name';

Performance Problem 3 — Missing Caching

AI-generated code hits the database for every request — including requests for data that rarely changes. Configuration values, category lists, product catalogues, site settings, and aggregated statistics are all recalculated from scratch on every request when a simple cache would serve the same data in microseconds.

// PROBLEMATIC — expensive queries on every request
public function getCategories(): JsonResponse
{
    // Called on every page load, result rarely changes
    $categories = Category::with('children')
        ->whereNull('parent_id')
        ->orderBy('sort_order')
        ->get();

    return response()->json($categories);
}

public function getDashboardStats(): JsonResponse
{
    // Expensive aggregations recalculated on every dashboard load
    return response()->json([
        'total_orders'   => Order::count(),
        'pending_orders' => Order::where('status', 'pending')->count(),
        'total_revenue'  => Order::where('status', 'delivered')->sum('total'),
        'new_customers'  => User::whereDate('created_at', today())->count(),
    ]);
}

// CORRECT — cache results that don't change frequently
public function getCategories(): JsonResponse
{
    $categories = Cache::remember('categories_tree', now()->addHours(6), function () {
        return Category::with('children')
            ->whereNull('parent_id')
            ->orderBy('sort_order')
            ->get();
    });

    return response()->json($categories);
}

// Clear cache when categories change
public static function boot(): void
{
    parent::boot();

    static::saved(fn() => Cache::forget('categories_tree'));
    static::deleted(fn() => Cache::forget('categories_tree'));
}

// Dashboard stats — cache with shorter TTL, refresh in background
public function getDashboardStats(): JsonResponse
{
    $stats = Cache::remember('dashboard_stats', now()->addMinutes(5), function () {
        return [
            'total_orders'   => Order::count(),
            'pending_orders' => Order::where('status', 'pending')->count(),
            'total_revenue'  => Order::where('status', 'delivered')->sum('total'),
            'new_customers'  => User::whereDate('created_at', today())->count(),
        ];
    });

    return response()->json($stats);
}

// For user-specific data — cache per user
public function getUserDashboard(Request $request): JsonResponse
{
    $cacheKey = "user_dashboard_{$request->user()->id}";

    $data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($request) {
        return [
            'orders'          => $request->user()->orders()->latest()->take(5)->get(),
            'loyalty_points'  => $request->user()->loyalty_points,
            'pending_returns' => $request->user()->returns()->where('status', 'pending')->count(),
        ];
    });

    return response()->json($data);
}

Finding cacheable queries — identify repeated expensive queries:

# In Laravel Telescope — filter by slow queries
# Look for:
# 1. Same query appearing in multiple requests with identical results
# 2. Aggregation queries (COUNT, SUM, AVG) running on large tables
# 3. Queries with many JOINs running on every request

# Profile with clock
$start = microtime(true);
$categories = Category::with('children')->whereNull('parent_id')->get();
$elapsed = (microtime(true) - $start) * 1000;

// Any query consistently taking >50ms on a warm database is a cache candidate

Performance Problem 4 — Inefficient Query Patterns

Beyond N+1 problems, AI-generated code produces several other inefficient query patterns — loading full records when only a few columns are needed, using collections for operations that should be done in the database, and making multiple queries that could be combined into one.

// PROBLEMATIC — inefficient query patterns
// Loading entire records when only one column is needed
public function getUserEmails(): array
{
    return User::all()->pluck('email')->toArray();
    // Loads ALL columns for ALL users into memory, then discards everything except email
}

// Doing in PHP what should be done in SQL
public function getTopProducts(): Collection
{
    $orders = Order::with('items')->get(); // Loads all orders into memory
    return $orders->flatMap->items        // Then processes in PHP
        ->groupBy('product_id')
        ->sortByDesc(fn($items) => $items->sum('quantity'))
        ->take(10);
}

// Multiple queries that should be one
public function getOrderSummary(int $orderId): array
{
    $order      = Order::find($orderId);
    $itemCount  = OrderItem::where('order_id', $orderId)->count();
    $total      = OrderItem::where('order_id', $orderId)->sum('subtotal');

    return ['order' => $order, 'item_count' => $itemCount, 'total' => $total];
}

// CORRECT — efficient equivalents
// Select only needed columns
public function getUserEmails(): array
{
    return User::pluck('email')->toArray();
    // SELECT email FROM users — minimal data transfer
}

// Aggregate in the database, not in PHP
public function getTopProducts(): Collection
{
    return OrderItem::select('product_id', DB::raw('SUM(quantity) as total_sold'))
        ->groupBy('product_id')
        ->orderByDesc('total_sold')
        ->with('product:id,name,price')
        ->limit(10)
        ->get();
    // One query, aggregation done by MySQL
}

// Single query with eager loaded aggregates
public function getOrderSummary(int $orderId): array
{
    $order = Order::withCount('items')
        ->withSum('items', 'subtotal')
        ->findOrFail($orderId);

    return [
        'order'      => $order,
        'item_count' => $order->items_count,
        'total'      => $order->items_sum_subtotal,
    ];
    // One query with computed aggregates
}

Performance Problem 5 — Synchronous Processing of Slow Operations

AI-generated code performs slow operations synchronously — sending emails, generating PDFs, calling external APIs, processing images — directly in the request/response cycle. The user waits for the operation to complete before receiving a response.

// PROBLEMATIC — slow operations blocking the response
public function submitApplication(Request $request): JsonResponse
{
    $application = Application::create($request->validated());

    // These three operations block the response — could take 5-15 seconds total
    Mail::to($request->user())->send(new ApplicationConfirmationMail($application));
    Mail::to(config('mail.admin'))->send(new NewApplicationNotificationMail($application));
    $this->pdfService->generateApplicationPdf($application); // Slow
    $this->crmService->createLead($application);             // External API call

    return response()->json(['message' => 'Application submitted'], 201);
    // User waits 5-15 seconds for this response
}

// CORRECT — queue slow operations, respond immediately
public function submitApplication(Request $request): JsonResponse
{
    $application = Application::create($request->validated());

    // Dispatch to queue — returns immediately
    SendApplicationConfirmation::dispatch($application);
    NotifyAdminOfApplication::dispatch($application);
    GenerateApplicationPdf::dispatch($application);
    SyncApplicationToCrm::dispatch($application);

    return response()->json(['message' => 'Application submitted'], 201);
    // Response returns in <200ms — queue workers handle the rest
}

// app/Jobs/SendApplicationConfirmation.php
class SendApplicationConfirmation implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60; // Retry after 60 seconds on failure

    public function __construct(private Application $application) {}

    public function handle(): void
    {
        Mail::to($this->application->user)
            ->send(new ApplicationConfirmationMail($this->application));
    }

    public function failed(\Throwable $exception): void
    {
        Log::error('Failed to send application confirmation', [
            'application_id' => $this->application->id,
            'error'          => $exception->getMessage(),
        ]);
    }
}

Finding synchronous slow operations:

# Find Mail::send() calls in controllers — should be queued
grep -rn "Mail::send\|Mail::to" app/Http/Controllers --include="*.php"

# Find external HTTP calls in controllers — should be queued
grep -rn "Http::get\|Http::post\|Guzzle\|curl_exec" app/Http/Controllers --include="*.php"

# Find PDF/image generation in controllers
grep -rn "PDF::\|Imagick\|GdImage\|Intervention" app/Http/Controllers --include="*.php"

# All of the above should be in Jobs, not Controllers

Performance Problem 6 — Unbounded Result Sets

AI-generated API endpoints frequently return all matching records without pagination — loading potentially thousands of records into memory, serialising them all, and sending them all to the client.

// PROBLEMATIC — unbounded query
public function index(): JsonResponse
{
    $products = Product::where('active', true)->get(); // Could be 50,000 records
    return ProductResource::collection($products);
    // Memory: potentially hundreds of MB
    // Response time: unpredictable
    // JSON response size: potentially tens of MB
}

// CORRECT — always paginate collection endpoints
public function index(Request $request): JsonResponse
{
    $products = Product::where('active', true)
        ->when($request->category_id, fn($q) => $q->where('category_id', $request->category_id))
        ->when($request->search, fn($q) => $q->where('name', 'LIKE', "%{$request->search}%"))
        ->orderBy('name')
        ->paginate($request->integer('per_page', 24));
        // Maximum 24 records per request regardless of total count

    return ProductResource::collection($products);
    // Includes pagination metadata: current_page, last_page, total, per_page
}

// For internal processing — chunk large datasets
public function exportAllOrders(): void
{
    // Never: Order::all()->each(fn($order) => $this->process($order));

    Order::chunk(500, function ($orders) {
        foreach ($orders as $order) {
            $this->process($order);
        }
        // 500 records loaded, processed, released from memory
        // Then next 500 loaded — constant memory usage regardless of total
    });

    // Or use lazy loading for read-only processing
    Order::lazy()->each(fn($order) => $this->process($order));
}

Finding unbounded queries:

# Find ->get() calls without preceding ->paginate() or ->limit()
grep -rn "->get()" app/Http/Controllers --include="*.php" | \
  grep -v "->limit\|->first\|->find\|paginate"

# Find collection endpoints returning without pagination metadata
grep -rn "Resource::collection" app/Http/Controllers --include="*.php" -B5 | \
  grep -v "paginate\|->limit"

The Performance Audit Process

Step 1 — Profile before optimising. Never optimise based on intuition. Use Laravel Telescope, Laravel Debugbar, or Clockwork to get actual data on which queries are slowest, which endpoints have the highest query counts, and where time is actually being spent. Optimising the wrong thing wastes effort and sometimes makes performance worse.

# Install Clockwork for detailed profiling
composer require --dev itsgoingd/clockwork

# Enable query logging in AppServiceProvider for development
DB::listen(function ($query) {
    if ($query->time > 50) {
        Log::channel('slow_queries')->warning('Slow query detected', [
            'sql'      => $query->sql,
            'bindings' => $query->bindings,
            'time'     => $query->time . 'ms',
        ]);
    }
});

Step 2 — Load test with realistic data volumes. AI-generated code is tested on development datasets. Performance problems are often invisible until data volumes match production. Before launching, seed your staging database with realistic data volumes and run load tests.

# Install k6 for load testing
# brew install k6 (Mac) or download from k6.io

# Basic load test script
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    vus: 50,        // 50 concurrent users
    duration: '2m', // Run for 2 minutes
};

export default function () {
    const response = http.get('https://staging.yourapp.com/api/products');
    
    check(response, {
        'status is 200':          (r) => r.status === 200,
        'response time < 500ms':  (r) => r.timings.duration < 500,
        'response time < 1000ms': (r) => r.timings.duration < 1000,
    });
    
    sleep(1);
}

# Run: k6 run load-test.js

Step 3 — Fix in priority order. Address performance issues by impact: N+1 queries first (highest impact per fix), missing indexes second (one migration affects many queries), unbounded queries third (pagination protects all result sets), then caching and async processing.

The Performance Audit Checklist

Query Efficiency

  • All relationship access in loops uses eager loading with with()
  • Only required columns are selected — no SELECT * on large tables
  • Aggregations (COUNT, SUM, AVG) are done in SQL not PHP collections
  • No queries inside loops — all batch operations use collections or chunking

Database Indexes

  • All foreign key columns have indexes
  • All columns used in WHERE clauses have indexes
  • All columns used in ORDER BY clauses have indexes
  • Composite indexes exist for common multi-column query patterns
  • EXPLAIN output shows no full table scans on production-scale data

Caching

  • Configuration and reference data (categories, settings) is cached
  • Expensive aggregations are cached with appropriate TTL
  • Cache is invalidated correctly when underlying data changes
  • Cache keys are namespaced to avoid collisions

Async Processing

  • Email sending uses queued jobs — no Mail::send() in controllers
  • External API calls use queued jobs
  • File generation (PDF, CSV export) uses queued jobs
  • Image processing uses queued jobs
  • Queue workers are configured and monitored in production

Result Set Management

  • All collection API endpoints use pagination
  • Large dataset processing uses chunk() or lazy()
  • Per-page limits are enforced — user cannot request unlimited records
  • Search results have reasonable maximum limits

Next in the series: Database Audit — reviewing AI-generated schemas, migrations, and queries for correctness, safety, and long-term maintainability.

If you want a performance audit run on your existing application — profiling, identifying bottlenecks, and a prioritised optimisation plan — our team at Softcrony is happy to help.

Leave a comment