Web Development Bug Report

Nginx 413 Request Entity Too Large: fixing uploads across Nginx, PHP and Laravel

Uploading a PDF larger than 1 MB failed with a vague error in our claims portal. Nginx returned 413, then PHP silently dropped files, then Laravel rejected them. Four layers each had their own size limit. Here is how we aligned them and tested every boundary.

Daniel Tran Daniel Tran @fun_dev · March 13, 2025 · 9 min read · 15 Views
Banner: file upload blocked by stacked size limits in Nginx, PHP and Laravel

Our insurance claims portal lets customers attach scanned documents. After we moved it from a managed PHP host to our own containers, the support team noticed a new pattern: customers saying "the upload button does nothing" or "it says something went wrong". The JavaScript console told a more precise story:

POST https://claims.example.com/api/claims/8812/documents 413 (Request Entity Too Large)
Uncaught (in promise) SyntaxError: Unexpected token '<', "<html>
<h"... is not valid JSON

The frontend expected JSON, got Nginx's HTML error page and crashed in the parsing step, which is why the user saw a generic message. The Nginx error log confirmed the first limit:

2025/03/04 10:21:36 [error] 29#29: *1841 client intended to send too large body: 2481152 bytes, client: 203.0.113.61, server: claims.example.com, request: "POST /api/claims/8812/documents HTTP/2.0", host: "claims.example.com"

Reproducing it

We generated files of specific sizes and posted them with curl:

for size in 900K 1500K 3M 9M 15M; do
  head -c "$size" /dev/urandom > "/tmp/test-$size.pdf"
  code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" \
    -F "file=@/tmp/test-$size.pdf;type=application/pdf" \
    https://staging-claims.example.com/api/claims/1/documents)
  echo "$size -> $code"
done
900K -> 201
1500K -> 413
3M -> 413
9M -> 413
15M -> 413

What we ruled out

  • A load balancer limit. The cloud load balancer in front passed bodies of any size in a direct test against a dummy endpoint, and the 413 page had Nginx's server header.
  • Laravel validation. Laravel's validation errors come back as 422 with JSON, not 413 HTML.
  • Disk space for temporary files. The container had plenty, and the request never reached PHP.

Root cause

The new Nginx config had no client_max_body_size, so it used the default of 1 MB. The old managed host had raised it for us years ago, which is why nobody remembered it existed. Once we raised it, we found two more layers with smaller limits than the product spec of 10 MB per file: PHP's defaults (upload_max_filesize = 2M, post_max_size = 8M) and a Laravel rule with max:5120, written for an older spec. Each layer failed in a different way.

What is happening under the hood

An upload passes several gates, and each has its own limit and failure mode:

Layer Setting Default What happens when exceeded
Nginx client_max_body_size 1m 413 HTML page, request never reaches PHP
PHP post_max_size 8M $_POST and $_FILES are empty, PHP logs a warning; Laravel throws PostTooLargeException (413)
PHP upload_max_filesize 2M File entry exists with error UPLOAD_ERR_INI_SIZE; Laravel's file validation fails with "failed to upload"
Laravel max: rule (kilobytes) none 422 with a validation message
PHP max_file_uploads 20 Extra files are silently dropped

Nginx checks the Content-Length header against client_max_body_size before reading the body, so a too-large upload is rejected early. That is efficient, but browsers may show a network error instead of the 413 if the server closes the connection while the client is still sending.

post_max_size applies to the whole multipart body, including all files and fields plus encoding overhead, so it must be larger than upload_max_filesize multiplied by the number of files you accept in one request.

Environment

Component Version / value
Web server Nginx 1.27.4 (official Docker image)
PHP PHP-FPM 8.3.17 (official php:8.3-fpm image)
Framework Laravel 11.x
Frontend Fetch API, multipart FormData
Product requirement Up to 5 files per request, 10 MB each

Minimal reproduction

# nginx default.conf: no client_max_body_size, so 1m applies
server {
    listen 80;
    root /var/www/public;
    location ~ \.php$ {
        fastcgi_pass app:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
// app/Http/Requests/StoreClaimDocumentRequest.php (old rule)
public function rules(): array
{
    return ['file' => ['required', 'file', 'mimes:pdf,jpg,png', 'max:5120']]; // 5 MB in KB
}

Who is affected

Any Nginx plus PHP application that accepts uploads above 1 MB, and the same pattern exists with other stacks: Node.js body parsers default to 100 KB for JSON, Django has DATA_UPLOAD_MAX_MEMORY_SIZE, and Kubernetes ingress-nginx needs its own annotation. Moving between hosts is the usual trigger, because the previous host's limits were never written down.

The fix

We decided on one number per layer, from the outside in, with each outer layer slightly larger than the inner one so the innermost layer, which can return a friendly JSON error, is the one that rejects.

1. Nginx: allow the whole request (5 files of 10 MB plus overhead), only on the upload route.

server {
    client_max_body_size 2m;   # sane default for everything else

    # Upload route: send straight to Laravel's front controller with a larger limit
    location ~ ^/api/claims/\d+/documents$ {
        client_max_body_size 55m;
        fastcgi_pass app:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root/index.php;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

We first tried try_files inside the upload location, and it still returned 413. Nginx re-selects the location after the internal redirect to /index.php, and the .php location's own 2 MB limit applied. Passing to FastCGI directly from the upload location keeps the large limit scoped to that one route.

2. PHP: match the product spec.

; docker/php/conf.d/uploads.ini
upload_max_filesize = 10M
post_max_size = 52M
max_file_uploads = 5

3. Laravel: the authoritative check, with a clear message.

public function rules(): array
{
    return [
        'files'   => ['required', 'array', 'max:5'],
        'files.*' => ['file', 'mimes:pdf,jpg,png', 'max:10240'], // 10 MB, in kilobytes
    ];
}

4. Frontend: check before sending, and handle non-JSON errors.

const MAX_BYTES = 10 * 1024 * 1024;
const tooBig = [...input.files].filter((f) => f.size > MAX_BYTES);
if (tooBig.length) return showError(`Each file must be 10 MB or smaller.`);

const res = await fetch(url, { method: 'POST', body: formData, headers: { Accept: 'application/json' } });
if (res.status === 413) return showError('The upload is too large.');
const data = res.headers.get('content-type')?.includes('application/json') ? await res.json() : null;

Sending Accept: application/json also makes Laravel render its own 413 for PostTooLargeException as JSON instead of an HTML page.

Alternatives and trade-offs

  • Direct-to-storage uploads with presigned URLs. The browser uploads straight to object storage and only sends the key to the API. This removes the body from your web tier entirely and is our plan for files above 25 MB. It needs extra work for validation and malware scanning after upload.
  • One global client_max_body_size 100m. Simpler, but every endpoint can now be sent 100 MB bodies, which makes slow-upload denial of service cheaper.
  • Chunked or resumable uploads (for example the tus protocol). Best for very large files and bad networks, but more moving parts.

How to test the fix

We turned the curl loop into a boundary test with expected codes:

check() { # size expected_code
  head -c "$1" /dev/urandom > /tmp/t.pdf
  got=$(curl -s -o /tmp/body -w '%{http_code}' -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN" \
    -F "files[]=@/tmp/t.pdf;type=application/pdf" https://staging-claims.example.com/api/claims/1/documents)
  [ "$got" = "$2" ] && echo "ok   $1 -> $got" || { echo "FAIL $1 -> $got (want $2)"; cat /tmp/body; }
}
check 1500K 201
check 9M    201
check 10M   201
check 11M   422
check 60M   413

The 11 MB case proved the request reached Laravel and was rejected with a readable message. We also added a Laravel feature test using UploadedFile::fake()->create('scan.pdf', 10241) to assert the validation rule.

Confirming in production

  • Nginx error log lines with "client intended to send too large body" on the upload route went from about 140 per day to zero; the few remaining ones are on other routes and are real abuse.
  • Successful document uploads per day rose by 31 percent, which roughly matched the share of scans between 1 MB and 10 MB in our historical data.
  • Support tickets tagged "upload" dropped from 9 per week to one.

Regression guard

  • The boundary script runs in the staging smoke test after each deploy.
  • Upload limits now live in one place, config/uploads.php, which feeds the Laravel rule, the frontend (via a config endpoint) and a startup check that compares them with ini_get('upload_max_filesize') and logs an error if PHP is lower than the app limit.
  • The Nginx and PHP ini files are in the repo, so a host move can no longer lose them.

Security implications

Raising body limits increases what a single request can consume: memory in buffers, temporary disk space and worker time. We kept the larger limit only on the upload route, kept client_body_timeout at 60 seconds to drop slow senders, and rate limited the upload endpoint per user. Bigger uploads also mean bigger risk files, so validation still checks MIME type from content rather than the extension, files are stored outside the web root with random names, and they are scanned before an adjuster can open them.

Prevention checklist

  • List every layer that can limit a request body and write the chosen values down.
  • Make the innermost layer the strictest, so users get a friendly error.
  • Scope large body limits to the routes that need them.
  • Make frontend error handling tolerate HTML and empty responses.
  • Test sizes just under, at and over each limit in CI.

References

Add a label

#nginx #laravel #php #file-upload #http-413
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.