Increase PHP upload size and identify which limit rejected a file
To accept a 20 MiB file, set upload_max_filesize = 20M, give the complete multipart request more
room with post_max_size = 25M, and make sure your application and web server allow it too. Changing
PHP’s settings cannot override a smaller limit in your upload handler. This guide shows how to find
the active settings and reproduce each failure over HTTP with a small local endpoint.
How to check max file upload size in PHP
Check the PHP process serving the upload URL. php --ini reports the command-line configuration;
it does not establish what FPM or Apache loaded. PHP can use
different configuration files for different server interfaces.
Save this as check_upload_size.php beside your upload endpoint and request it through the same
website. Restrict it to administrator access on an existing site, then remove it after diagnosis:
the file paths are useful to you but should not be public.
<?php
header('Content-Type: application/json');
echo json_encode([
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'loaded_ini' => php_ini_loaded_file(),
'scanned_ini' => php_ini_scanned_files(),
'file_uploads' => ini_get('file_uploads'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'post_max_size' => ini_get('post_max_size'),
'max_file_uploads' => ini_get('max_file_uploads'),
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
PHP documents defaults of 2M for upload_max_filesize, 8M for post_max_size, and 20 for
max_file_uploads; installations can override them. The first two are byte limits, while
max_file_uploads limits the number of files. In PHP’s size notation, 20M means 20 × 1,024 × 1,024
bytes, or 20 MiB. See the core directives and
size parser.
| Layer | What it limits | What to look for |
|---|---|---|
| Web server or proxy | The incoming request body | A rejection before the PHP handler runs; inspect that server’s logs |
post_max_size | The entire POST body, including fields and multipart overhead | Both $_POST and $_FILES can be empty |
upload_max_filesize | Each uploaded file | UPLOAD_ERR_INI_SIZE in the file’s error field |
| Application validation | The file your application will accept | A handler constant or framework rule that may be smaller than PHP’s limit |
PHP documents the empty-superglobal behavior
and the upload error codes.
An empty $_FILES alone does not prove an oversized request: a missing file field can produce it too.
How to increase file upload size in PHP
Edit the configuration used by the web request, using the diagnostic output above to locate it.
On Ubuntu, a packaged PHP 8.3 installation commonly uses /etc/php/8.3/fpm/php.ini for FPM or
/etc/php/8.3/apache2/php.ini for mod_php. The reported path and effective values take precedence
over those examples.
file_uploads = On
upload_max_filesize = 20M
post_max_size = 25M
display_errors = Off
log_errors = On
Reload the service that runs PHP after changing its configuration. With FPM, that means the relevant
FPM service; with mod_php, it means Apache. Request the diagnostic URL again and verify the effective
values. These two size directives are INI_PERDIR settings, so ini_set() inside upload.php
cannot raise them. Disabling error display in configuration also keeps warnings emitted during
request parsing out of the JSON response.
Using PHP-FPM configuration
A pool can override php.ini. Check the pool serving this site if the values still differ. For a
20 MiB limit, the corresponding pool settings are
php_admin_value[upload_max_filesize] = 20M and php_admin_value[post_max_size] = 25M.
FPM’s configuration reference
explains pool overrides. Reload the affected FPM service and recheck over HTTP.
Using .htaccess (for Apache 2.4+ with mod_php)
For PHP running as an Apache module, php_value upload_max_filesize 20M and
php_value post_max_size 25M can go in .htaccess when the server permits these overrides.
They are not PHP-FPM settings; do not add them merely because Apache fronts the site.
See PHP’s Apache configuration instructions.
If the request never reaches PHP, check the upstream body cap. Nginx’s
client_max_body_size
defaults to 1m and returns 413 for an oversized request. For this example, client_max_body_size 25m;
allows the intended multipart request. Apache has
LimitRequestBody, and a hosting
proxy may impose another cap. Allow space for the whole request at every layer. Raising a PHP limit
does not alter any of these settings.
Implementing file upload functionality in PHP
Use a local Linux environment with 64-bit PHP 8.3 or later, the Fileinfo extension, and cURL. The example was exercised with PHP 8.3.30 and 8.5.10. PHP’s built-in server is for local development. It does not reproduce an FPM, Apache, or proxy deployment.
Create a fresh directory. The chained commands stop if the directory already exists or navigation fails; choose another name instead of overwriting an existing project.
mkdir php-upload-demo &&
cd php-upload-demo &&
mkdir public private &&
chmod 700 private
Continue only after this succeeds. Save the INI block above as php-upload.ini in this directory.
Save the diagnostic script as public/check_upload_size.php.
PHP upload script
Save this complete handler as public/upload.php. APP_MAX_BYTES is the application’s independent
cap; here it agrees with PHP’s 20 MiB per-file limit. The handler accepts JPEG, PNG, and PDF files,
checks the detected MIME type against the filename extension, and keeps accepted bytes under
private/, outside the document root.
<?php
declare(strict_types=1);
const APP_MAX_BYTES = 20 * 1024 * 1024;
header('Content-Type: application/json');
function rejectUpload(int $status, string $code, string $message): never {
http_response_code($status);
echo json_encode([
'success' => false,
'code' => $code,
'message' => $message,
], JSON_THROW_ON_ERROR);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
rejectUpload(405, 'method', 'Send a multipart POST request.');
}
$postLimit = ini_parse_quantity(ini_get('post_max_size'));
$contentLength = $_SERVER['CONTENT_LENGTH'] ?? null;
if ($postLimit > 0 && $contentLength !== null && (int) $contentLength > $postLimit) {
rejectUpload(413, 'post_max_size', 'The complete request exceeds PHP post_max_size.');
}
$file = $_FILES['file'] ?? null;
if (!is_array($file) || array_keys($_FILES) !== ['file'] ||
!isset($file['error'], $file['size'], $file['name'], $file['tmp_name']) ||
!is_int($file['error']) || !is_int($file['size']) ||
!is_string($file['name']) || !is_string($file['tmp_name'])) {
rejectUpload(400, 'invalid_upload', 'Send one file in the file field, without array brackets.');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
[$status, $code, $message] = match ($file['error']) {
UPLOAD_ERR_INI_SIZE => [413, 'upload_max_filesize', 'The file exceeds PHP upload_max_filesize.'],
UPLOAD_ERR_FORM_SIZE => [413, 'form_limit', 'The file exceeds the submitted MAX_FILE_SIZE.'],
UPLOAD_ERR_PARTIAL => [400, 'partial_upload', 'The file arrived incomplete. Retry the upload.'],
UPLOAD_ERR_NO_FILE => [400, 'missing_file', 'Choose a file to upload.'],
default => [500, 'upload_unavailable', 'The upload service is unavailable.'],
};
rejectUpload($status, $code, $message);
}
if (!is_uploaded_file($file['tmp_name'])) {
rejectUpload(400, 'invalid_upload', 'The file is not a valid HTTP upload.');
}
$directory = null;
$destination = null;
try {
$size = filesize($file['tmp_name']);
if ($size === false) {
throw new RuntimeException('Cannot measure upload');
}
if ($size === 0) {
rejectUpload(400, 'empty_file', 'The file is empty.');
}
if ($size > APP_MAX_BYTES) {
rejectUpload(413, 'application_limit', 'The file exceeds the application size limit.');
}
$allowed = [
'image/jpeg' => ['jpg', 'jpeg'],
'image/png' => ['png'],
'application/pdf' => ['pdf'],
];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!isset($allowed[$mime]) || !in_array($extension, $allowed[$mime], true)) {
rejectUpload(415, 'file_type', 'Send a JPEG, PNG, or PDF with a matching extension.');
}
$hash = hash_file('sha256', $file['tmp_name']);
if ($hash === false) {
throw new RuntimeException('Cannot hash upload');
}
$id = bin2hex(random_bytes(16));
$candidate = dirname(__DIR__) . '/private/' . $id;
// Creating a new directory reserves this ID without overwriting an earlier upload.
if (!@mkdir($candidate, 0700)) {
throw new RuntimeException('Cannot reserve storage');
}
$directory = $candidate;
$destination = $directory . '/file.' . $allowed[$mime][0];
if (!@move_uploaded_file($file['tmp_name'], $destination) || !@chmod($destination, 0600)) {
throw new RuntimeException('Cannot store upload');
}
http_response_code(201);
echo json_encode([
'success' => true,
'id' => $id,
'type' => $mime,
'size' => $size,
'sha256' => $hash,
], JSON_THROW_ON_ERROR);
} catch (Throwable $error) {
if ($destination !== null) {
@unlink($destination);
}
if ($directory !== null) {
@rmdir($directory);
}
error_log('Upload failed: ' . get_class($error));
rejectUpload(500, 'upload_unavailable', 'The upload service is unavailable.');
}
The request-size check uses Content-Length to distinguish a known oversized body from a missing
file. The cURL requests below supply that length. Without it, the handler cannot diagnose a
post_max_size overflow this way; enforce a body limit in the web server for clients using streamed
requests. ini_parse_quantity() handles PHP’s size suffixes, including a zero limit, which disables
the multipart POST size cap.
Start the server from php-upload-demo. Choose an available port and use the same port in the
following requests. This command serves only public/:
php -c php-upload.ini -S 127.0.0.1:8080 -t public
Leave that terminal running. In a second terminal, open the same project directory and check:
curl -fsS http://127.0.0.1:8080/check_upload_size.php
Expect sapi to be cli-server, upload_max_filesize to be 20M, and post_max_size to be 25M.
The loaded INI path should point to this project. Stop the server with Ctrl+C when finished.
Send real multipart requests
For a reproducible size probe, save this as make-probe.php at the project root. It creates a valid
one-pixel PNG padded to the requested byte size. The padding is deliberate: it tests byte limits
without requiring a large photograph, and demonstrates why MIME detection is not a safety verdict.
The exclusive create mode refuses to overwrite an existing probe.
<?php
declare(strict_types=1);
$bytes = filter_var($argv[2] ?? '', FILTER_VALIDATE_INT);
if ($argc !== 3 || $bytes === false || $bytes < 1024 || $bytes > 30 * 1024 * 1024) {
fwrite(STDERR, "Usage: php make-probe.php OUTPUT BYTES (1024 to 31457280)\n");
exit(1);
}
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg==', true);
$output = @fopen($argv[1], 'xb');
if ($output === false) {
fwrite(STDERR, "Cannot create probe; choose a new output filename.\n");
exit(1);
}
if (fwrite($output, $png) !== strlen($png) || !ftruncate($output, $bytes) || !fclose($output)) {
fwrite(STDERR, "Cannot finish probe.\n");
exit(1);
}
Create and send a 6 MiB file. -F supplies the multipart encoding and boundary; do not set the
Content-Type header yourself. -i shows the response status. These diagnostic calls omit cURL’s
--fail so that you can read the JSON body for expected error responses.
php make-probe.php probe-6.png 6291456 &&
curl -sS -i -F 'file=@probe-6.png' http://127.0.0.1:8080/upload.php
Expect HTTP 201 with success: true, type: "image/png", size: 6291456, a random id, and a
sha256. The bytes remain in private/<id>/file.png; there is no public download URL. Compare the
response hash with a local hash of the source:
php -r 'echo hash_file("sha256", "probe-6.png"), PHP_EOL;'
Repeat with the exact 20 MiB boundary, then with files large enough to trigger each PHP limit:
php make-probe.php probe-20.png 20971520 &&
curl -sS -i -F 'file=@probe-20.png' http://127.0.0.1:8080/upload.php
php make-probe.php probe-21.png 22020096 &&
curl -sS -i -F 'file=@probe-21.png' http://127.0.0.1:8080/upload.php
php make-probe.php probe-26.png 27262976 &&
curl -sS -i -F 'file=@probe-26.png' http://127.0.0.1:8080/upload.php
| File | Expected result with the documented settings |
|---|---|
| 6 MiB PNG | 201; stored with matching bytes and hash |
| 20 MiB PNG | 201; the per-file boundary is inclusive |
| 21 MiB PNG | 413 with code: "upload_max_filesize" |
| 26 MiB PNG | 413 with code: "post_max_size"; the complete request is too large |
To isolate the application cap, stop the server and restart it with this temporary override:
php -c php-upload.ini -d upload_max_filesize=24M -S 127.0.0.1:8080 -t public
Send the existing 21 MiB probe again:
curl -sS -i -F 'file=@probe-21.png' http://127.0.0.1:8080/upload.php
Now expect 413 with code: "application_limit": PHP permits the file, but APP_MAX_BYTES rejects
it. Restore the original server command afterward. If an existing application still rejects a
6 MiB file after you raise PHP’s limits, look for a smaller application cap such as 5242880
bytes, or a framework validation rule. Changing php.ini does not update that rule.
The probe commands never replace an existing probe. To resend one, run only its cURL command. Each successful request creates a new private upload, even if the contents are identical; failures create no retained upload. The demo keeps successful uploads until you remove its private data.
Best practices for secure file uploads
Directory security
Keep private/ outside the served directory and owned by the PHP account. Each reserved upload
directory has mode 0700; stored files have mode 0600. The original filename never becomes a
storage path. PHP’s move_uploaded_file()
checks that the source was uploaded through PHP, but it can overwrite an existing destination.
Reserving a fresh random directory before moving the file avoids that overwrite in this example.
Before exposing an upload endpoint
This local example has no authentication, user quotas, malware scanner, or public file serving. Fileinfo identifies a likely type from the bytes; neither its result nor a SHA-256 hash establishes that a file is harmless. For a deployed application, decide who may upload, bound their storage usage, and validate or scan content before releasing it. Frameworks and upload progress displays still sit behind the same request and file limits; they do not remove them.
