Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

PHP Performance Mistakes Hiding in Your Loops and Queries

About Post

When a PHP page is slow, people blame PHP. Then they open a profiler and find the real culprit: a single innocent-looking line inside a foreach, running a database query once for every row.

Modern PHP is fast. What's slow is asking it to do the same expensive thing thousands of times without noticing. Most of these mistakes are invisible with test data and painful with real data, which is exactly why they reach production.

Here are six I see over and over, with the fix for each, and one "performance tip" you can safely forget.

1. A query inside the loop

The classic N+1 problem. It hides behind Eloquent's lazy loading, so the code doesn't even look like it's running queries.

// Before: 1 query for contracts + 1 per contract for the tenant
$contracts = Contract::where('status', 'active')->get();

foreach ($contracts as $contract) {
    echo $contract->tenant->name;
}

// After: 2 queries in total, however many contracts there are
$contracts = Contract::with('tenant')->where('status', 'active')->get();

Make Laravel catch this for you during development. In AppServiceProvider::boot():

Model::preventLazyLoading(! app()->isProduction());

Now any lazy-loaded relationship throws an exception locally, so you see the problem the first time you load the page instead of when the table is big.

2. Counting and checking with a query per row

A quieter cousin of N+1: asking a yes/no or how-many question for each row.

// Before: one COUNT query per contract
foreach ($contracts as $contract) {
    $overdue = $contract->payments()->where('status', 'overdue')->count();
}

// After: the counts come back with the contracts, in the same query
$contracts = Contract::withCount([
    'payments as overdue_count' => fn ($q) => $q->where('status', 'overdue'),
])->get();

For simple "does it have any?" checks, withExists('payments') adds a payments_exists boolean the same way.

3. Repeating an expensive lookup on every iteration

Not every slow loop involves the database. Searching an array inside a loop is a hidden nested loop.

// Before: in_array scans $blockedIds from the start every time
foreach ($users as $user) {
    if (in_array($user['id'], $blockedIds)) { /* ... */ }
}

// After: build a lookup once, then each check is a direct hash lookup
$blocked = array_flip($blockedIds);

foreach ($users as $user) {
    if (isset($blocked[$user['id']])) { /* ... */ }
}

With small arrays, nobody will notice. With tens of thousands of users and IDs, the first version does a huge amount of pointless work. The same goes for anything that gives the same answer every iteration: parsing the same date, reading the same setting, building the same regex or calling the same external service. Hoist it above the loop.

4. array_merge in a loop

This one looks harmless and isn't:

// Before: copies the ever-growing $rows array on every iteration
$rows = [];
foreach ($files as $file) {
    $rows = array_merge($rows, parseFile($file));
}

// After: collect the pieces, merge once at the end
$parts = [];
foreach ($files as $file) {
    $parts[] = parseFile($file);
}
$rows = array_merge(...$parts);

array_merge builds a brand-new array each time, copying everything collected so far. The total work grows with the square of the data. Appending with $parts[] = and merging once keeps it linear.

A related point about array copies: PHP uses copy-on-write, so passing a big array to a function is cheap. The copy only happens if the function modifies it. You don't need references to "save memory" in normal code; you just need to avoid changing large arrays you've been handed.

5. Building a giant string (the real problem, not the myth)

Here's the myth: "string concatenation in loops is slow, use implode." In PHP, appending with .= is efficient, and for most code the difference isn't worth thinking about.

The real problem is what you're building. Exporting a large CSV by concatenating every row into one string means holding the entire file in memory before sending a single byte. Stream it instead:

return response()->streamDownload(function () {
    $out = fopen('php://output', 'w');
    fputcsv($out, ['Contract', 'Tenant', 'Status']);

    Contract::with('tenant')->lazyById(500)->each(function ($c) use ($out) {
        fputcsv($out, [$c->number, $c->tenant->name, $c->status]);
    });

    fclose($out);
}, 'contracts.csv');

Memory stays flat no matter how many rows there are, and the download starts immediately.

6. Loading the whole table into memory

Contract::all() on a large table hydrates every row into a full Eloquent model at once. It works fine until the table grows, and then the job dies with "Allowed memory size exhausted".

Process large sets in batches:

  • chunkById(500, fn ($contracts) => ...) processes 500 at a time.
  • lazyById(500) gives you a lazy collection that fetches in batches behind the scenes, so you can write a simple each().
  • cursor() streams one model at a time with a single query, but can't eager load relationships.

A gotcha worth knowing: if you update the column you're filtering on while using plain chunk(), the offsets shift and rows get skipped. chunkById() pages by primary key, so it doesn't have that problem. Use it by default.

The rule: look at every loop and ask, "what does this line cost, times the number of rows in production?" A query, an array scan or a copy that's cheap once can be very expensive ten thousand times.

Measure before you optimise

Don't guess. Laravel Debugbar or Telescope show query counts per request, and a sudden jump from a handful to hundreds is the clearest N+1 alarm there is. For CPU-heavy code, a profiler like Xdebug's or Blackfire shows exactly which function eats the time.

And skip the micro-optimisations. Caching count() outside a for condition, or choosing single quotes over double quotes, won't rescue a page that runs a query per row.

Checklist for your next code review

  • Any relationship accessed inside a loop? Eager load it.
  • Any count(), exists() or first() inside a loop? Use withCount or withExists.
  • Any in_array or repeated parsing inside a loop? Build a lookup or hoist it.
  • Any array_merge accumulating inside a loop? Collect, then merge once.
  • Any big export built in memory? Stream it.
  • Any all() or get() on a table that will grow? Chunk it.

Which of these have you found in production code? And what's the slowest loop you've ever had the pleasure of fixing?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close