"The server is slow at random times and nobody knows why." On a travel platform we took over this year, the CPU graph had spikes that matched nothing in the business: no campaign, no booking rush, no deploy. Average CPU sat around 5%. The peaks did not.
The cause was scanner bots. On an average day about 1,200 of their requests reached PHP-FPM, every one of them asking for a file that had never existed. After we refused them in Nginx, that number went to zero and stayed there. This is how we found them, the rules we used, and the one rule that bit us.
Why a 404 can cost you a PHP worker
A bot asks a Laravel site for /wp-login.php. There is no WordPress here, so the answer is a 404. The question is who produces that 404.
In a typical Nginx configuration, any path ending in .php is handed to PHP-FPM. PHP-FPM takes a worker, looks for the file, fails, and logs Primary script unknown. Paths that do not end in .php fall through to the framework's front controller, which boots the whole application to render a 404 page. Either way a PHP worker is busy doing nothing useful.
One request costs almost nothing. A scanner sending a thousand of them in under a minute takes every worker you have, and your real visitors queue behind it.
What the logs showed
The platform had no monitoring when we inherited it. Once CloudWatch was in place and the spikes were visible, we took two weeks of Nginx logs (75,546 access records and 26,096 error-log entries) and had them analysed with AI assistance, matching 15-minute log buckets against the CPU chart. The picture was not subtle:
- Every CPU spike we had a timestamp for lined up with a scanner burst.
- More than half the requests (40,314) carried no user agent at all.
- Single IP addresses sent 1,000 requests in under a minute, or about 100 in 13 seconds.
- They asked for
/wp-login.php,/xmlrpc.php, WordPress plugin paths,/.env,/.env.production,/.git/config,/actuator/env,/_ignition/health-checkand/api/.aws/credentials, plus URL-encoded variants such as/%2eenvbuilt to slip past naive rules. - The error logs counted the cost: on an average day about 1,190 scanner requests reached PHP-FPM, with bad days above 2,000 and one day in early July at 3,466.
The spikes were not customers, and they were not a search engine crawling the site.
Why we did not block by user agent
The obvious fix is a list of bad user agents. It does not work. One IP rotated through Googlebot, ClaudeBot, PerplexityBot, ChatGPT-User and OAI-SearchBot while asking for .env and .git/config. Others sent perfectly ordinary Chrome and Firefox strings. A user-agent rule either misses them or blocks the real Googlebot along with the fake one.
So we ignored who the requests claimed to be and looked at two things the bots cannot disguise: what they ask for and how fast.
The rules: refuse the request before PHP sees it
A Laravel application has exactly one PHP file that should ever execute: public/index.php. That makes the most effective rule also the simplest. Any other .php path is refused, along with WordPress directories, dotfiles, encoded dotfile probes and framework debug endpoints:
# URL-encoded probes for dotfiles: /%2eenv, /%2egit, ...
if ($request_uri ~* "(%2e|%252e)(env|git|svn|hg|aws|ssh)") {
return 444;
}
# Any .php file except the front controller
if ($request_uri ~* "^/(?!index\.php(?:[/?]|$)).*\.php(?:[/?]|$)") {
return 444;
}
location ~* ^/(wp-admin|wp-content|wp-includes)(/|$) { return 444; }
location ~* ^/(actuator|_ignition|cgi-bin)(/|$) { return 444; }
location ~ /\.(?!well-known) { return 444; }
444 is Nginx's own status: close the connection without sending anything back. No response body, no PHP, almost no cost. The .well-known exception keeps certificate renewal working.
If your application has other legitimate PHP entry points, such as legacy scripts or a separate admin tool, list them before you deploy the .php rule, not after.
Then a per-IP rate limit on whatever still reaches PHP. Static files are not limited, only requests that cost a worker:
limit_req_zone $binary_remote_addr zone=dynamic:10m rate=120r/m;
limit_req_status 429;
location = /index.php {
limit_req zone=dynamic burst=30 nodelay;
# ... fastcgi_pass as before
}
120 requests a minute per IP, with a burst of 30, is far above anything a person clicking through the site will produce.
The rule that bit us
The rate limit counted every request from one IP address the same way, and the client's own staff do not browse like customers. Uploading a batch of images through the admin file manager fires dozens of requests in seconds. It tripped the limiter in production, and once that was fixed, other back-office routes did the same.
The fix is a map that gives those routes an empty key. Nginx does not count requests whose key is empty, so they bypass the limit while everything public stays protected:
map $request_uri $limit_key {
default $binary_remote_addr;
~^/filemanager/upload(?:/|\?|$) "";
~^/admin(?:/|\?|$) "";
}
limit_req_zone $limit_key zone=dynamic:10m rate=120r/m;
The lesson is about who you test with, not about Nginx. Rules like these are written with bots in mind, and the people most likely to trip them are the ones who use the admin panel every day. Rate limits are for anonymous public traffic: decide which routes are back-office before you switch them on, and ask the people who use them to try it.
What it changed
The rules went live on 7 July. From the logs on either side of that date:
- Before: about 1,190 scanner requests a day reached PHP-FPM, as many as 3,466 in a single day.
- The day of the change: 410.
- Every day after: zero, for as long as we have logs.
- The error log itself fell from 500 to 6,500 lines a day to between 5 and 170. That matters on its own: when the log is not buried under scanner noise, the real errors in it are visible again.
The load dropped, and we are deliberately not putting a percentage or a dollar figure on it. What it bought was a server whose graphs reflected real traffic, which is what made the next decision possible. That decision, a resize that halved the instance, is written up in how we cut one AWS bill by 70%.
A checklist for your own server
- Count how often scanners already reach PHP:
grep -c "Primary script unknown" /var/log/nginx/error.log. Hundreds a day means they are. - List every PHP file that should legitimately execute. For a Laravel or Symfony application it is usually one.
- Refuse everything else ending in
.php, plus WordPress paths, dotfiles, encoded dotfile probes and debug endpoints, with444, before anyfastcgi_pass. - Rate-limit what still reaches PHP, per IP, and exempt back-office and upload routes before you deploy, not after.
- Do not block by user agent. The scanners already claim to be Googlebot.
- Check that real crawlers can still fetch the site, for example with a live test in Google Search Console.
- Count again the next day. The number you are aiming for is zero.
Where we fit
This was one step in stabilising a platform we inherited, alongside backups, security closures and a rehearsed server upgrade. The whole engagement is in the inherited Laravel platform case study.
If your server has spikes nobody can explain, the logs usually can. Reading them against your infrastructure is part of every System Risk Check. Keeping the rules current afterwards is ordinary PHP application maintenance, or Laravel maintenance and support if that is what you run.





