Support had a strange one. Logging in as specific customers killed the summary page. Not slow, dead. On-prem, behind the F5, the browser got this:
The requested URL was rejected. Please consult with your administrator.
Your support ID is 1f27571e-...
Error 504 - Gateway Timeout The same app also runs in Azure, part of a cloud migration that had both environments live at once. There, the same page eventually rendered. After a couple of minutes. Same code, same data, and every other customer loaded instantly.
The setup
Symfony 7.1 on PHP 8.3, a customer portal for an equipment leasing company. Customers log in and see a summary of their contracts. There's no database for business data: everything comes from a backend REST API, cached in APCu with a ten-minute TTL. The summary page makes three or four HTTP calls to it regardless of how many contracts you have.
Contract PDFs live on a shared network drive (SMB on-prem, Azure Files in the cloud), and the app reads that drive with symfony/finder.
The customers that broke had one thing in common: hundreds of contracts. Customer-service impersonation was the worst case of all, because CS users deliberately see the full unfiltered history where regular customers get an 18-month cutoff.
Nothing scaled with contract count. Except this.
"Big customers are slow" usually means a slow query or an API N+1. There was no query, and the API layer was three or four fixed, cached calls. I went looking for anything in the request path that grew with the number of contracts and found exactly one thing, in the controller:
$contractsToBurst = [];
foreach ($contracts as $contract) {
$documents = $documentRepository->findByContract($contract);
if (count($documents) === 0) {
$contractsToBurst[] = $contract;
}
}
$PDFBurstService->submitContracts($contractsToBurst); And in the repository:
$finder = new Finder();
$finder->files()
->in($this->documentsPath)
->name($contract->getNumber() . '*')
->sortByModifiedTime(); Three traps stacked on top of each other.
Finder::in() is recursive by default. Every call walks the entire documents directory, every customer's PDFs, not just this one's. name() filters the results after the walk. It doesn't make the walk any shorter.
sortByModifiedTime() adds a stat() per matched file. The walk already does metadata lookups on every entry to decide whether to recurse into it, and the sort piles more on top. On a local SSD you never notice. Over SMB those lookups go to another machine, and the client's attribute cache only saves you some of them.
The loop multiplies it. Cost is contracts × files on the share. Hundreds of contracts, tens of thousands of files, and network latency on the lookups adds up to minutes. The F5 gave up first. Azure just suffered for longer.
The kicker is what all this work was for. The page never displays any of it. The loop exists to compute "which contracts have no extracted PDF yet" so they can be posted, fire-and-forget, to a PDF extraction service. A side effect, paid for on the request path, on the most visited page in the app, on every single load.
It's an N+1 problem. It's just nowhere your APM tells you to look: not SQL, not HTTP. The filesystem is the database you forgot you had.
One walk instead of hundreds
The fix is about forty lines in two parts.
First, a repository method that walks the directory once, unsorted, and matches filenames against contract numbers with a hash lookup. An existence check doesn't need ordering, and dropping the sort drops the extra stat() calls with it:
public function findContractsWithoutDocuments(array $contracts): array
{
if (count($contracts) === 0) {
return [];
}
$numbersByLength = [];
foreach ($contracts as $contract) {
$numbersByLength[strlen($contract->getNumber())][$contract->getNumber()] = true;
}
$finder = (new Finder())->files()->in($this->documentsPath);
$numbersWithDocuments = [];
foreach ($finder as $file) {
$filename = $file->getFilename();
foreach ($numbersByLength as $length => $numbers) {
$prefix = substr($filename, 0, $length);
if (isset($numbers[$prefix])) {
$numbersWithDocuments[$prefix] = true;
}
}
}
return array_values(array_filter(
$contracts,
fn(Contract $contract): bool => !isset($numbersWithDocuments[$contract->getNumber()])
));
} The length-indexed lookup keeps the semantics of the old name($number . '*') glob (filename starts with the contract number) without running a pattern match per file per contract. That holds for plain alphanumeric contract numbers. If yours can contain glob characters like ? or *, the old code was matching more than you thought anyway.
Second, don't do it on every page load. The check runs once per session per customer, behind a session flag. Documents don't appear mid-session often enough to matter, and the on-demand path elsewhere in the app covers the gap.
A micro-benchmark on local disk, which is the best case for the old code, with 20,000 files and 200 contracts:
old (N x findByContract): 23.74s
new (1 x findContractsWithoutDocuments): 0.098s
speedup: 242x
results identical: YES On the real share, with more files and real latency, the gap is a lot wider. And with the session guard, repeat loads pay nothing.
Takeaway
N+1 isn't a database concept. Any per-item operation with a hidden full-collection cost qualifies, and Finder in a loop is the filesystem version. No profiler category points at it.
Sorting results you only count is pure waste, and on network filesystems the metadata lookups are the expensive part. If the page doesn't render it, ask whether the request should compute it at all.
Environment latency is a multiplier, not a cause. On-prem 504'd, Azure took minutes, dev never noticed, with the same O(N×M) everywhere and different constants. When slowness scales differently across environments, suspect I/O counts before code paths.
Fighting a mystery timeout of your own? Send me your problem and I'll take a look.
