One Thursday morning the site came up with a 500 error. We had no deploy, no code had changed, and the database was in place. Stranger still, when we went to read the log to see what had happened, the last line was from a few minutes earlier and after that nothing; as if the log itself had been cut off.

A few minutes later the host's email arrived: the disk quota is full. A simple du exposed the culprit: a file named storage/logs/laravel.log that had quietly grown for months and now took several gigabytes.

This kind of failure gives no gradual warning. As long as one byte is left everything works normally; the moment the quota runs out everything breaks at once: sessions, cache, uploads, and even writing the very log that was supposed to explain what happened. This note is that path: diagnosis, cause, and setting up log rotation on shared hosting so it does not happen again.

Symptoms: from slowness to write errors#

A full disk quota has no single symptom. Depending on which operation hits the wall first, you see different things:

  • A white page or a 500 error on all routes, even routes that do nothing with files.
  • In the web server log or PHP's error output, messages like: No space left on device or failed to open stream: Disk quota exceeded.
  • Sometimes you see Permission denied instead. This one is misleading, because you immediately go looking for chmod when the problem is not permissions.
  • A user logs in but is immediately kicked out, because the session driver is file-based and can no longer write the session file.
  • A file upload fails or an incomplete file is saved.
  • Noticeable slowness: every request waits on a failed write.
  • The host's warning email with a subject like "disk quota warning". It usually arrives when it is too late.

The important point is that none of these name the culprit file. The system only says there is no room; finding what ate the room is your job.

Finding the culprit with du and ls#

First see how much space is used at all. In cPanel the Disk Usage section shows this, but it is quicker over SSH:

Bash
du -sh ~

Then go one layer down and sort the biggest folders:

Bash
du -sh ~/* | sort -h

sort -h sorts numbers with suffixes (K, M, G) correctly. Now repeat the same thing inside the app folder until you reach storage:

Bash
du -sh ~/app/storage/* | sort -h
ls -lh ~/app/storage/logs

If the log is the culprit, ls -lh shows it at once: a laravel.log whose size is nowhere near the scale of the other files.

To be sure no other big thing is left, list all files over a hundred megabytes in the user's home:

Bash
find ~ -type f -size +100M -exec ls -lh {} \; 2>/dev/null

The 2>/dev/null is so that "no access" errors on system folders do not clutter the output. If the output is empty, the problem is the number of small files, not one giant file; we get to that in the section on other consumers.

Why laravel.log grows without limit#

The short answer: because nobody told it not to. Laravel by default is set to a driver that has just one file and always appends to the end of that one file. There is no ceiling, no rotation, no automatic deletion.

Four things make this growth fast:

  1. The single driver. This driver means "everything in laravel.log, forever". Until you touch it yourself, the file only gets bigger.
  1. The debug log level in production. The default value of LOG_LEVEL in many sample files is debug. That is the lowest possible level; meaning whatever any package decides to report gets written.
  1. A repeating error. The worst case is this: an exception that happens on every request and writes a full stack trace each time. One stack trace can be several kilobytes. Multiply that by a month of requests and it becomes a scary number.
  1. Bots. Crawlers and scanners hit odd paths, generate 404 and MethodNotAllowed, and if these get logged too, the log volume has nothing to do with the number of your real users.

On a dedicated server this situation is bad too but shows itself later. On shared hosting where the disk quota is often a few gigabytes, a repeating error swallows the whole quota in a few weeks.

Emergency cleanup: why rm is the wrong move#

The first temptation is to delete the file:

Bash
rm ~/app/storage/logs/laravel.log

It works, but it has two risks.

Risk one: ownership and permissions. With rm the file and all its attributes are gone. Whichever process writes a log first recreates the next file, with that process's ownership, permissions and umask. If on your host the PHP user differs from the SSH user, or cron jobs run with different settings, the next file may be unwritable for one of the two. Then instead of "the disk is full" you get "Permission denied" — the same misleading symptom mentioned above.

Risk two: the space is not really freed. If a process keeps the file open — such as a queue:work that is running permanently — deleting the file name does not give the disk space back. The operating system keeps the space until the last file descriptor is closed. You run rm, du says everything is fine, but the quota is still full.

The right way is to empty the file's content, not delete it:

Bash
truncate -s 0 ~/app/storage/logs/laravel.log

If truncate is not on the host, do the same with the shell itself:

Bash
: > ~/app/storage/logs/laravel.log

Both keep the file with the same inode, the same owner and the same permissions and only set its length to zero. Processes that opened the file in append mode — which Monolog does — keep writing without problems. If you want to keep the last few lines for inspection before emptying, take a small copy first:

Bash
tail -n 2000 ~/app/storage/logs/laravel.log > ~/last-errors.txt
truncate -s 0 ~/app/storage/logs/laravel.log

This cleanup is only an emergency. The site comes back but nothing is solved; tomorrow it starts growing again.

The real cure: log rotation with the daily driver#

The real fix is changing the driver from single to daily. Instead of one file, daily creates a separate file for each day and deletes files older than a set number of days by itself.

In .env:

INI
LOG_CHANNEL=daily
LOG_LEVEL=warning
LOG_DAILY_DAYS=14

A version note: in newer Laravel versions the default value of LOG_CHANNEL is stack, and the stack channel itself decides which channels to write to. If you are on stack, the deciding variable is LOG_STACK, not LOG_CHANNEL:

INI
LOG_CHANNEL=stack
LOG_STACK=daily
LOG_LEVEL=warning
LOG_DAILY_DAYS=14

First make sure which case you have: open the config/logging.php file and see what the stack channel reads from. The daily section usually looks like this:

PHP
'daily' => [
    'driver' => 'daily',
    'path' => storage_path('logs/laravel.log'),
    'level' => env('LOG_LEVEL', 'debug'),
    'days' => (int) env('LOG_DAILY_DAYS', 14),
    'replace_placeholders' => true,
],

And after every change to .env, clear the config cache, or nothing happens:

Bash
php artisan config:clear

If you cache config on the server, run php artisan config:cache again. And if you changed values and saw no change, suspect the line endings of the .env file itself too; an invisible character at the end of a line can corrupt a value without telling you anything.

A comparison of one log file growing endlessly with seven daily files whose oldest is deleted

When exactly log rotation happens (and when it does not)#

It is good to know what happens under the hood, because there are a few traps.

daily uses a handler that builds the file name with the date: instead of laravel.log you see files like laravel-2026-09-07.log. When a log is written and today's date differs from the current file name, the handler "rotates": it creates today's file and at the same moment counts the older files of the same pattern and deletes those beyond days.

Three practical consequences of this behaviour:

  • Deletion is not scheduled; it is event-driven. If the site writes no log for several weeks, no file is deleted either. In that case, though, nothing has grown, so there is no problem.
  • It does not delete the old laravel.log file by itself. The deletion pattern only matches dated files. That multi-gigabyte laravel.log from before stays there until you delete it once yourself. Be sure to check this, or the quota is still full and you think the problem is solved.
  • Zero means infinity. If you set LOG_DAILY_DAYS to zero, no file is deleted. Write the number explicitly.

For the number of days, 7 to 14 is enough for an ordinary store. If you have a few megabytes of log a day and your quota is tight, 7 is more conservative. If your log is light and sometimes you need to go back two weeks, 14 is more reasonable.

Log level: why debug is expensive in production#

The log level means "the floor of importance that I write". Any message below this floor never goes to disk. The order of levels is standard:

LevelWhat it meansIn production
debugFine details for developmentNo
infoNormal, expected eventsUsually no
noticeNormal but noteworthy eventsDepends
warningSomething is not right but work continuesYes
errorAn operation failedYes
critical and abovePart of the system is downYes

For most production sites warning is the right point: you catch exceptions and failures and not the chattiness of packages. If you want to cut the volume even more and only real failures matter to you, error is defensible too — as long as you know you lose the "not broken yet but about to break" warnings.

Let us also clarify a common mistake: APP_DEBUG and LOG_LEVEL are two separate things. APP_DEBUG=false only stops error details being shown to the user; writing to the log stays in place. If you are unsure about this separation, our note on APP_DEBUG on shared hosting opens up exactly this.

The stack channel and separating important errors#

daily with warning solves the volume problem, but it has a side effect: really important errors get lost among the heap of ordinary warnings. The clean way is the stack channel; that is, a message goes to several destinations at once, each with its own importance floor.

In config/logging.php, add a separate channel for important errors:

PHP
'critical' => [
    'driver' => 'daily',
    'path' => storage_path('logs/critical.log'),
    'level' => 'error',
    'days' => 60,
    'replace_placeholders' => true,
],

And in .env:

INI
LOG_CHANNEL=stack
LOG_STACK=daily,critical
LOG_LEVEL=warning
LOG_DAILY_DAYS=14

Now you have two streams. laravel-YYYY-MM-DD.log keeps everything from warning up and is deleted after two weeks. critical-YYYY-MM-DD.log takes only error and above, stays small, and can stay two months without bothering the disk. When a customer says "my payment got stuck last week", you know exactly which file to open.

A weekly cron job and an early quota warning#

daily does its own job, but a cheap safety net helps. Set up a weekly cron job that sweeps old dated files — the ones that may have been left by other channels or from before the settings:

Bash
0 4 * * 0 /usr/bin/find /home/USER/app/storage/logs -name '*.log' -mtime +30 -delete

-mtime +30 means files that have not changed for more than 30 days. Because it uses the modification date, today's file is never deleted.

And a reporting cron job that tells you usage once a week. cPanel emails you the cron output, so this one line is enough to be warned before you reach the ceiling:

Bash
0 7 * * 1 /usr/bin/du -sh /home/USER /home/USER/app/storage /home/USER/public_html

If the output never reaches your email or the cron job fails silently, first suspect the difference between the cron shell and the interactive shell; we dissected this in the note on PHP 8.4 in cPanel cron jobs. In short, in cron there is no alias and no custom PATH; write the full path of commands, as we did in the examples above.

Other things that eat the disk quota#

The log is usually the biggest culprit but not the only one. When the quota gets tight, look at these four places too.

The share of disk usage between logs, backups, the image cache, sessions and the framework cache

Forgotten backups. A backup script you wrote a few months ago that creates a sql.gz every night but never deletes the old ones. These are big single files and are found with the same find -size +100M above. A backup on the same disk that may fill up does not count as a backup; take a copy somewhere else.

Image and thumbnail cache. If you build several sizes for each image, the image cache folder can become bigger than the original images themselves. Especially if old versions are not deleted after a size change. If you work with GD, the note on image processing with GD is written about these same limits.

Session files. With the file driver, every visitor — including every bot — creates a file in storage/framework/sessions. Each file is small, but their number can reach tens of thousands, and on some hosts the inode count limit fills up before the size limit. Then du shows a small number and you are left confused.

Framework cache. storage/framework/cache/data and storage/framework/views are usually negligible, but if the cache driver is file-based and you cache big things, they grow. Clearing them is harmless:

Bash
php artisan view:clear
php artisan cache:clear

To count session files and see whether their number has gone out of control:

Bash
ls -1 ~/app/storage/framework/sessions | wc -l

If you want one full review of the limits of this kind of host, the shared hosting checklist for Laravel puts these together.

Logs must not be readable from the web#

One point that is usually forgotten when the disk fills up: the content of the log is sensitive. Stack traces may include file paths, query parameters, tokens and sometimes variable values. This is not something you want to be public.

In Laravel's standard structure, storage is outside the web root and there is no problem. But on some shared hosts the whole project is put inside public_html, and then storage/logs is reachable through the browser. Try once: open the log file's URL in the browser. If you see anything, close it that moment. In the .htaccess file in the root:

Apache
<FilesMatch "\.log$">
    Require all denied
</FilesMatch>

And better than that, fix the structure so that storage is not under the web root at all and only the public folder stays outside. This takes effort once and removes a whole class of security problems for good.

The main lesson#

A disk filled by a log is not a bug; it is a missing decision. Laravel does not ask you how long the log should stay, and if you do not answer, the default answer is "forever".

Three lines in .env make this decision: the daily driver, the warning level, and a set number of days. A weekly cron job is a safety net, not the solution. And if your disk is full right now, run truncate first so the site can breathe, then go to the settings — not the other way round.

And one habit that has served us many times: whenever a file grows for no reason, before deleting it read its last few hundred lines. They usually say what error repeated a thousand times a day — and that error, not the file size, is your real problem.