Customers messaged that the site would not come up. We opened it and instead of the home page we saw a dry white page with one sentence in the middle: Resource Limit Is Reached. No logo, no error page of our own, not even a trace of Laravel. The status code was 508, a number we had only ever seen in the table of HTTP codes.
The first thing we did was wrong: we went for storage/logs/laravel.log. Spotless. No exception, no new line. We were confused for a few minutes until it clicked — the request had never reached Laravel to log anything. Somewhere ahead of us, before PHP even started, someone had closed the door.
This note is the path we followed afterwards: what 508 is and who makes it, which four limits are counted separately on shared hosting, how to see in cPanel which one is full, and above all, what really eats the quota in a file store.
The symptom: a page that does not come from Laravel#
The signs usually look like this:
- A plain, unstyled page with the text
Resource Limit Is ReachedorError 508. - The error is not continuous. One refresh gives the error, the next shows the site. A few minutes later, the error again.
storage/logs/laravel.loghas no new lines.- Sometimes cPanel itself gets slow and SSH or FTP connections respond late.
The first thing to make sure of is that you really get 508 and not something else:
curl -sI https://example.com/ | head -n 1If the output is HTTP/1.1 508 or HTTP/2 508, that is it. From here on, do not look for a bug in your code. A bug may exist and it may even be what filled the limit, but what produced this page is not your code.
508 Resource Limit Reached belongs to CloudLinux, not Laravel#
Most cPanel shared hosts run on CloudLinux — not all; if you are unsure, ask your host's support. CloudLinux puts each cPanel account inside a lightweight container called an LVE — short for Lightweight Virtual Environment. This container is a wall around your account: whatever your processes consume is charged to your account, and when it goes over the limit, the web server module (mod_hostinglimits) rejects the new request and returns that static page.
A subtle point that causes confusion: in the HTTP standard, code 508 means "Loop Detected" and belongs to WebDAV. CloudLinux and cPanel borrowed this code to say "you hit the limit". So if you look up the meaning of 508 in HTTP documentation, you land on a misleading place.
The practical consequence is that this error is produced before PHP. Not your try/catch, not Laravel's error handler, not middleware — none of them run. That is why the log is empty.
And a more important point: the limit applies to the whole account, not to one site or one script. If an old WordPress, a backup script and your Laravel store all sit side by side on the same account, all three draw from the same bag. Your app being light does not guarantee anything on its own.
Four limits counted separately#
A common mistake is to think "resources" is a single number. It is not. LVE has at least four independent counters and hitting any one of them is enough:
| Limit | What it counts | How it usually shows itself |
|---|---|---|
| CPU | The percentage of one or more cores your processes take | The site gets slow, responses take long |
| Physical memory (PMEM) | The total real RAM of all the account's processes | A process is suddenly killed, white page or 500 |
| Entry processes (EP) | The number of requests running at the same time | The 508 page |
| Input/output (I/O) | Disk read and write speed | Everything gets slow, files arrive late |
Depending on the CloudLinux version and host settings, you may see other counters too: IOPS (disk operations per second) and NPROC (total number of processes). These are independent as well.
Now a point that is often left unsaid: the limit that directly produces the 508 page is usually Entry Process. The other limits fill it up indirectly.
The chain is this: a slow query or a heavy job makes every request take several seconds instead of two hundred milliseconds. Requests pile up. The number of simultaneous requests goes over the EP limit. The next request gets 508. So what you see is "busyness", but what caused it is "slowness". If you only look for ways to reduce traffic, you have not solved the problem.
Reading the Resource Usage section in cPanel#
In cPanel, under the Metrics category, there is an option called Resource Usage (on some themes it is called CloudLinux LVE Manager). This page shows exactly the counters above. The path and look depend on the version, but three things are always there:
- A summary at the top that says whether you hit a limit in the last 24 hours.
- A table or charts broken down by resource: CPU, Physical Memory, Entry Processes, I/O, and sometimes IOPS and Number of Processes.
- A column named Faults — and this is the most important column on the whole page.
Each "fault" means one time you hit a limit. If the Faults column has a number for Entry Processes and zero for the others, you have your answer: the problem is concurrency. If Faults is against Physical Memory, look for a memory-heavy job. The instantaneous usage number can fool you, because at the moment you look everything is calm; Faults is history and does not lie.
The charts also have a time axis. Take that axis seriously. If peaks repeat exactly on the hour or every five minutes, a cron job is almost certainly involved. If the peaks are irregular and drawn out, it is probably traffic or a bot.
The lveinfo command often seen in CloudLinux documentation does not work for you on shared hosting — it needs root access. The Resource Usage page shows the same information to the extent that concerns you.
Cause one: a cron job running several instances at once#
This is the most common cause we have hit ourselves. A cron job calls schedule:run every minute. One job in the scheduler gets heavy and takes four minutes. On the second minute, cron runs again and a second instance starts. By the fourth minute, four simultaneous instances of the same job are sitting on the database. The CPU and EP limits fill up together.
Diagnosis. Count your own processes. On LVE, the ps command shows only your own account's processes, which is exactly what we want:
ps -u $USER -o pid,etime,pcpu,rss,args --sort=-pcpu | head -n 30Look at the etime column. If you see several artisan lines with an elapsed time of several minutes, that is the answer. For a quick count:
ps -u $USER -o args= | grep -c '[a]rtisan schedule:run'If you want to do the same count from inside PHP, you will probably hit a wall: on most shared hosts exec and shell_exec are blocked. We wrote about this restriction and its workarounds separately in the note on disable_functions on shared hosting.
Cure. Use two layers. The first layer, inside Laravel:
$schedule->command('reports:build')
->hourly()
->withoutOverlapping(10);The number 10 means the lock releases itself after ten minutes, so that if a process is killed midway the job does not stay locked forever.
The second layer is outside Laravel, on cron itself. withoutOverlapping helps once PHP has started; if starting PHP itself is expensive, several instances still boot. flock solves this:
flock -n /home/USER/locks/schedule.lock -c '/usr/local/bin/ea-php84 /home/USER/app/artisan schedule:run' >> /home/USER/logs/schedule.log 2>&1The -n switch means "if the lock is held, do not wait at all, return immediately". Create the locks folder beforehand. Also write the full path of the PHP binary; we explained why writing a bare php in a cPanel cron job is a trap in the note on PHP 8.4 in cPanel cron jobs.
Cause two: crawler bots and concurrent requests#
A file store usually has filter pages: category, tag, sorting, pagination. Combining these creates an infinite space of URLs, and bots love infinite spaces. A commercial crawler can send several concurrent requests, and that alone can fill the EP limit.
Diagnosis. Read the web server access log. Its path varies by cPanel version; it is usually one of these two: ~/access-logs/ or ~/logs/. The most active IPs:
awk '{print $1}' ~/access-logs/example.com | sort | uniq -c | sort -rn | head -n 20The most active user agents:
awk -F'"' '{print $6}' ~/access-logs/example.com | sort | uniq -c | sort -rn | head -n 20And the busiest minutes, which tell you when the peak was so you can compare with the cPanel chart:
awk '{print substr($4,2,17)}' ~/access-logs/example.com | uniq -c | sort -rn | head -n 15If you see a single IP or a specific bot that made thousands of requests in a few minutes, that is the answer.
Cure. Three steps, in this order:
- Close the infinite space. Disallow filter and sort URLs in
robots.txtand set a propercanonicalon duplicate pages. This is the most fundamental step. - Limit the rate. For heavy routes such as search, use Laravel's
throttlemiddleware:
Route::get('/search', SearchController::class)
->middleware('throttle:20,1');- If a particular bot is worth nothing to you, block it in
.htaccess:
RewriteEngine On
RewriteCond %{HTTP_USER_AGENT} (AhrefsBot|SemrushBot|MJ12bot|DotBot) [NC]
RewriteRule ^ - [F,L]Do not be free with blocking. A user agent can be forged, and if you block Googlebot you suffer a loss far costlier than an hour of 508.
Cause three: a query without an index#
A query that does a full scan over ten thousand rows costs both CPU and I/O on shared hosting. Worse, the PHP process waits the whole time and keeps its place occupied in the EP limit.
Diagnosis. The MySQL slow query log is usually not available to you on shared hosting. So build your own. In AppServiceProvider, and only for a short debugging period:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function ($query) {
if ($query->time > 300) {
Log::warning('slow query', [
'ms' => $query->time,
'sql' => $query->sql,
]);
}
});$query->time is in milliseconds. After a few hours, read the log and pick out the repeated queries. Then run EXPLAIN on that query:
EXPLAIN SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;If you see ALL in the type column and an empty key, you have no index.
Cure.
ALTER TABLE orders ADD INDEX orders_user_created_idx (user_id, created_at);Column order matters: first the column you filter by, then the column you sort by. Alongside that, look for the N+1 problem too; a foreach loop that calls a relationship inside can create a hundred queries in one request on its own. Do not forget with().
And for pages whose data does not change every second, a short cache works wonders:
$featured = Cache::remember('home:featured', 300, function () {
return Product::query()->featured()->limit(12)->get();
});Five minutes of caching on the home page means that however many visits come in those five minutes, the database is hit only once.
Cause four: image processing and the physical memory limit#
There is a subtle trap here. memory_limit in PHP is the limit of one process. PMEM is the limit of all the account's processes combined. That means five simultaneous uploads, each comfortably under memory_limit, can together fill the account's memory limit. None of them broke the rule individually, but together they did.
Images are also the hungriest common job. When you open a JPEG with GD, the file leaves its compressed form and is spread out in memory at roughly "width × height × four bytes". A four-thousand by three-thousand pixel photo, even if the file is two megabytes, takes tens of megabytes in memory — and if you also hold the resized version at the same time, double.
Cure.
- Read the dimensions with
getimagesize()before processing and, if they are larger than your allowed limit, politely refuse. - Take processing out of the request cycle. Save the upload, create a queue job, and answer the user immediately.
- Release the image reference early (
unset($img)) so its memory is freed right there. In PHP 8 and laterimagedestroy()no longer does anything and freeing is left to reference counting. - In the queue, keep concurrency at one. Two parallel resize jobs on shared hosting means double the memory.
If your host has no ImageMagick and you have to build everything with GD, we cover the details and limits in the note on image processing with GD.
Cause five: an unlocked queue, file sessions and a big log#
Three things that each quietly eat the I/O limit:
An unlocked queue. If you run queue:work from cron and have not added a lock, the story of the first cause repeats; with the difference that each extra worker keeps polling the database. For shared hosting this pattern works well:
flock -n /home/USER/locks/queue.lock -c '/usr/local/bin/ea-php84 /home/USER/app/artisan queue:work --stop-when-empty --max-time=50 --tries=3'--max-time=50 means the worker leaves by itself after fifty seconds so that cron brings a new one the next minute. A permanent process on shared hosting gets killed sooner or later.
File sessions. With SESSION_DRIVER=file, every visitor creates a small file in storage/framework/sessions. With bot traffic this folder reaches tens of thousands of files and every request has to search in that same folder. See how big it has become:
find ~/app/storage/framework/sessions -type f | wc -l
du -sh ~/app/storage/framework/sessionsIf the number is big, move the driver to database (its table either exists already or is created with a migration, depending on the Laravel version) and empty the folder.
A big log. LOG_CHANNEL=single means everything goes into one laravel.log that keeps growing. A file of several hundred megabytes makes every write expensive. Switch to daily and limit the number of retention days. See the current size:
ls -lh ~/app/storage/logs/508 versus 503 and 500#
People mix these three up a lot. A short table:
| Code | Who produces it | First place to look |
|---|---|---|
| 500 | Your own code — an exception, a syntax error, a wrong setting | storage/logs/laravel.log and the web server error log |
| 503 | Laravel's maintenance mode or the backend being down | The existence of the maintenance file, the PHP service status |
| 508 | LVE on your cPanel account | The Resource Usage section in cPanel, the Faults column |
A simple diagnostic point: if the Laravel log has nothing, it is not a 500. A 500 almost always leaves a trace. And if you did not run php artisan down and no maintenance file exists, a 503 is also unlikely.
One warning too: when the site throws an error, you are tempted to turn on APP_DEBUG to see what is going on. For 508 it does not help at all — because PHP is not running — and leaving it on in a live site is a security risk.
Prevention: habits that kept us under the limit#
- Every cron job that might take long gets
flock. No exceptions. - Every heavy job (images, reports, bulk email, zip files) moves out of the request cycle.
- Run the queue with concurrency of one, with
--max-timeand--stop-when-empty. - Expensive routes such as search and filters get
throttle. robots.txtcloses off the infinite space of filters.- Logs on
dailywith limited retention; sessions and cache on the database or memory, not files. - Once a month, look at the Resource Usage page and the Faults column, not only when the site goes down.
- Before bringing a project up on shared hosting, run through the shared hosting checklist for Laravel once; half of these traps are closed there.
The main lesson#
508 is not an application error, it is an accounting notice: the server is saying your quota is used up. That is why looking for the error in the app log is useless and you have to start somewhere else — from the Faults column in cPanel, which tells you which of the four limits is full.
And once you know which limit, take one step back. A full Entry Process limit is usually an effect, not the cause; the cause is slowness. Making one query faster or moving one heavy job out of the request cycle keeps you away from the limit more than any setting.