WordPress never sets the upload ceiling — PHP does
To increase the WordPress max upload file size you change a PHP directive, not a WordPress setting. The number in the media library comes from wp_max_upload_size(), which reads upload_max_filesize and post_max_size with ini_get() and returns whichever is smaller. Both directives are PHP_INI_PERDIR: they can be set in php.ini, .user.ini, httpd.conf or .htaccess, and ini_set() at runtime returns false. That single fact explains why so many "increase upload size" snippets appear to work and then fail on the first real upload.
You need shell or hosting-panel access to the server, the ability to restart PHP (or the patience to wait out the .user.ini cache), and a test file large enough to prove the change. Everything below assumes you can reach php.ini or the WordPress document root.
Three places to change the limit, and how to tell which one your host honours
- Hosting panel or php.ini — the reliable route. cPanel has MultiPHP INI Editor, Plesk has PHP Settings, and most managed hosts expose a PHP configuration screen. On a VPS you edit
/etc/php/8.2/fpm/php.iniand restart the FPM service. This survives WordPress and plugin updates. - .user.ini in the WordPress root — the same directives in a plain text file, read by PHP-FPM and other FastCGI SAPIs. It is cached for
user_ini.cache_ttlseconds (300 by default), so an edit can take five minutes to appear. mod_php ignores it entirely. - .htaccess with
php_value— only when PHP runs as an Apache module. On PHP-FPM, LiteSpeed or suPHP it either does nothing or throws a 500. Use it only after confirming mod_php is in play.
Choose by what the host gives you, not by what the snippet you found uses. A panel writes the correct file for the correct SAPI and restarts the pool for you. .user.ini is the fallback when there is no PHP settings screen but you can upload files to the document root. .htaccess belongs to legacy mod_php stacks and should be removed again the moment the host migrates to FPM.
The php.ini values that have to move together
Raising one directive is the classic mistake. upload_max_filesize caps a single file; post_max_size caps the whole request body, multipart wrapper included. If post_max_size is smaller, PHP discards the request before WordPress sees it: $_FILES is empty, $_POST is empty, and the form appears to do nothing at all — no error, no log entry.
; php.ini or .user.ini — no trailing comments after a value file_uploads = On upload_max_filesize = 64M post_max_size = 68M memory_limit = 256M max_execution_time = 300 max_input_time = 300 Why each line matters:
-
upload_max_filesize— the per-file ceiling. This is the number the media library prints. -
post_max_size— set it a few megabytes above the file limit so the multipart wrapper and any extra form fields still fit. -
memory_limit— at least as large aspost_max_size, realistically two to three times larger. WordPress loads the image into memory to generate sub-sizes, and GD or Imagick needs several copies of the pixel data, not just the compressed file size. -
max_execution_timeandmax_input_time— a 60 MB file on a slow connection takes longer than the default 30 seconds to arrive, and the request dies mid-upload. -
file_uploads— if this is Off, no upload can succeed regardless of the other values.
Restart PHP-FPM or Apache after editing php.ini. Saving the file without a reload changes nothing, and it is the most common reason a correct edit appears to have no effect.
When .htaccess is the right file, and when it takes the site down
Apache only, mod_php only. The <IfModule> guard stops the block from being fatal on a server without that module — which also means a wrong module name fails silently instead of loudly.
# Only honoured when PHP runs as an Apache module (mod_php). # Module names differ between builds: mod_php.c, mod_php7.c, mod_php8.c. <IfModule mod_php.c> php_value upload_max_filesize 64M php_value post_max_size 68M php_value memory_limit 256M php_value max_execution_time 300 </IfModule> Two failure modes to recognise. A 500 Internal Server Error immediately after saving means PHP is not running as a module — delete the block and switch to .user.ini. A site that loads normally but still reports the old limit means the <IfModule> name did not match, so Apache skipped everything inside it. Check with wp eval 'echo php_sapi_name();': apache2handler means mod_php, fpm-fcgi means .htaccess will never work.
Multisite adds a second ceiling above the PHP one
On a network, Network Admin → Settings → Max upload file size caps every site in the network on top of the PHP values. It is stored as the fileupload_maxk site option in kilobytes, so the default 1500 means roughly 1.5 MB. Raising php.ini to 64 MB while that field still says 1500 leaves you at 1.5 MB, and the media library will keep reporting the smaller number. Change both, then check a single site's Media → Add New screen rather than the network admin.
Per-site storage quotas are a separate wall. If a site has a quota set and it is full, the upload is rejected with a quota message even though the file is well under the size limit. Network Admin → Sites → Edit → Settings shows the quota, and the upload_space_check_disabled site option turns the check off for the whole network.
Build a diagnostics panel that reads the values PHP actually reports
Rather than guessing which php.ini won, print the live values from inside WordPress. This must-use plugin adds Tools → Upload Limits, compares each directive against a target, and flags the combinations that break uploads. It belongs in wp-content/mu-plugins/ so it loads before ordinary plugins and cannot be deactivated by accident.
<?php /** * Plugin Name: WPTH Upload Limit Diagnostics * Description: Prints the PHP upload limits that actually apply and flags mismatches between them. * Version: 1.0.0 */ defined( 'ABSPATH' ) || exit; /** * Convert a php.ini shorthand size (64M, 1G, 524288) to bytes. * Returns -1 for the unlimited sentinel that memory_limit accepts. */ function wpth_ini_bytes( string $value ): int { $value = trim( $value ); if ( '' === $value ) { return 0; } if ( '-1' === $value ) { return -1; } $number = (float) $value; switch ( strtolower( substr( $value, -1 ) ) ) { case 'g': $number *= 1024; // Fall through to megabytes. case 'm': $number *= 1024; // Fall through to kilobytes. case 'k': $number *= 1024; } return (int) $number; } /** * The values the server should end up with. Filterable per site. */ function wpth_upload_targets(): array { return (array) apply_filters( 'wpth_upload_targets', array( 'upload_max_filesize' => '64M', 'post_max_size' => '68M', 'memory_limit' => '256M', ) ); } /** * Compare the live ini values against the targets. */ function wpth_upload_report(): array { $report = array(); foreach ( wpth_upload_targets() as $directive => $target ) { $current = (string) ini_get( $directive ); $have = wpth_ini_bytes( $current ); $want = wpth_ini_bytes( (string) $target ); $report[ $directive ] = array( 'current' => $current, 'target' => (string) $target, 'ok' => ( -1 === $have ) || ( $have >= $want ), ); } return $report; } /** * Combinations that break uploads even when each directive looks fine. */ function wpth_upload_checks(): array { $report = wpth_upload_report(); $upload = wpth_ini_bytes( $report['upload_max_filesize']['current'] ); $post = wpth_ini_bytes( $report['post_max_size']['current'] ); $memory = wpth_ini_bytes( $report['memory_limit']['current'] ); $time = (int) ini_get( 'max_execution_time' ); return array( array( 'label' => 'file_uploads is enabled', 'ok' => (bool) ini_get( 'file_uploads' ), 'note' => ini_get( 'file_uploads' ) ? 'On' : 'Off - every upload is rejected.', ), array( 'label' => 'post_max_size covers upload_max_filesize', 'ok' => $post >= $upload, 'note' => sprintf( 'post_max_size %s vs upload_max_filesize %s', $report['post_max_size']['current'], $report['upload_max_filesize']['current'] ), ), array( 'label' => 'memory_limit leaves room to process the file', 'ok' => ( -1 === $memory ) || ( $memory >= $post ), 'note' => sprintf( 'memory_limit %s vs post_max_size %s', $report['memory_limit']['current'], $report['post_max_size']['current'] ), ), array( 'label' => 'max_execution_time allows a slow upload to finish', 'ok' => ( 0 === $time ) || ( $time >= 60 ), 'note' => sprintf( 'max_execution_time %d seconds', $time ), ), ); } /** * The block to paste into php.ini or .user.ini. */ function wpth_upload_ini_snippet(): string { $lines = array( '; Raise the ceiling for large media, plugin and theme ZIPs.', 'file_uploads = On', ); foreach ( wpth_upload_targets() as $directive => $target ) { $lines[] = sprintf( '%s = %s', $directive, $target ); } $lines[] = 'max_execution_time = 300'; $lines[] = 'max_input_time = 300'; return implode( "\n", $lines ); } /** * Tools -> Upload Limits. */ function wpth_render_upload_limits_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $report = wpth_upload_report(); $checks = wpth_upload_checks(); $ceiling = wp_max_upload_size(); ?> <div class="wrap"> <h1>Upload Limit Diagnostics</h1> <p>These values come from <code>ini_get()</code> on this request, not from the number the media library prints.</p> <h2>Effective WordPress limit: <?php echo $ceiling ? esc_html( size_format( $ceiling ) ) : 'uploads disabled'; ?></h2> <table class="widefat striped" style="max-width:900px"> <thead> <tr> <th scope="col">Directive</th> <th scope="col">Current value</th> <th scope="col">Target</th> <th scope="col">Status</th> </tr> </thead> <tbody> <?php foreach ( $report as $directive => $row ) : ?> <tr> <td><code><?php echo esc_html( $directive ); ?></code></td> <td><?php echo esc_html( '' === $row['current'] ? '(empty)' : $row['current'] ); ?></td> <td><?php echo esc_html( $row['target'] ); ?></td> <td><?php echo esc_html( $row['ok'] ? 'OK' : 'Below target' ); ?></td> </tr> <?php endforeach; ?> </tbody> </table> <h2>Consistency checks</h2> <ul> <?php foreach ( $checks as $check ) : ?> <li> <strong><?php echo esc_html( $check['ok'] ? 'OK' : 'FAIL' ); ?></strong> - <?php echo esc_html( $check['label'] ); ?> (<?php echo esc_html( $check['note'] ); ?>) </li> <?php endforeach; ?> </ul> <h2>Paste this into php.ini or .user.ini</h2> <pre><?php echo esc_html( wpth_upload_ini_snippet() ); ?></pre> </div> <?php } add_action( 'admin_menu', function () { add_management_page( 'Upload Limit Diagnostics', 'Upload Limits', 'manage_options', 'wpth-upload-limits', 'wpth_render_upload_limits_page' ); } ); Three details in that file are worth copying into your own diagnostics:
-
ini_get()rather thanwp_max_upload_size()for the table. The WordPress function returns one combined number; you need to see which of the two directives is the smaller one before you can fix it. - A shorthand parser.
ini_get()returns the string exactly as written —64M,1G,524288— so every comparison has to happen in bytes. The deliberate switch fall-through converts G to M to K to bytes in a single pass. - The
-1sentinel.memory_limit = -1means unlimited, and a naive byte comparison would report it as the smallest value on the page.
Targets are filterable through wpth_upload_targets, so a site that only needs 8 MB can lower them without editing the plugin, and a staging copy can raise them without a second file.
Tools → Upload Limits showing the live ini values, the targets and the consistency checks (test site: WordPress 7.1.3, PHP 8.2)
Read the same numbers from WP-CLI before you open the browser
WP-CLI runs under the command-line SAPI, which often uses a different php.ini from the FPM pool that serves the site. That makes it a fast way to confirm which file you edited — and a trap if you assume the CLI values are the web values.
wp eval 'foreach ( array( "upload_max_filesize", "post_max_size", "memory_limit", "max_execution_time", "file_uploads" ) as $directive ) { printf( "%-22s %s\n", $directive, ini_get( $directive ) ); }' wp eval 'echo size_format( wp_max_upload_size() ), "\n";' If the CLI shows 64M and the diagnostics page shows 2M, you edited the CLI ini and the web pool is still on its own file. Look in /etc/php/8.2/fpm/pool.d/*.conf for a php_admin_value[upload_max_filesize] line: a pool-level value overrides php.ini and cannot be changed from .user.ini at all.
The five PHP directives the tutorial edits, as reported by ini_get() under WP-CLI (test site: WordPress 7.1.3, PHP 8.2):
upload_max_filesize 2M post_max_size 8M memory_limit -1 max_execution_time 0 file_uploads 1 Prove it with a real upload, then read the failure mode
Numbers in a table are not proof. Upload a file just under the new limit through Media → Add New, and check that the thumbnail is generated as well as the file arriving — thumbnail generation is where memory_limit shows up. If the media library still prints the old figure, the change never reached the SAPI that serves the site.
When it still fails, the error tells you which layer rejected it:
- The uploaded file exceeds the upload_max_filesize directive in php.ini — PHP rejected the file. The web pool is still on the old value, or a pool-level
php_admin_valueis overriding you. - Nothing happens, no error, empty media library —
post_max_sizeis smaller than the file. PHP discarded the request body silently, so there is nothing for WordPress to report. - File uploads but no thumbnail, or a white screen —
memory_limitran out insideWP_Image_Editor. Raise it to at least twicepost_max_size. - Request times out at 30 or 60 seconds —
max_execution_time,max_input_time, or a proxy in front of PHP. - 413 Request Entity Too Large from nginx or a CDN — PHP was never reached. nginx needs
client_max_body_size 68m;in the server block, and Cloudflare's free plan stops at 100 MB regardless of your php.ini.
Do not reach for wp media import to script this check. It copies a file that is already on the server, so it never meets upload_max_filesize or post_max_size: on the test site it imported a 5 MB file while the limit was 2M and exited with 0. Only a real upload, through the browser or an HTTP request, exercises the limits.
wp media import accepting a 5 MB file while upload_max_filesize is 2M (test site: WordPress 7.1.3, PHP 8.2):
$ wp eval 'echo ini_get( "upload_max_filesize" ), "\n";' 2M $ head -c 5242880 /dev/urandom > /tmp/large-test.zip && ls -lh /tmp/large-test.zip | awk '{print $5, $9}' 5.0M /tmp/large-test.zip $ wp media import /tmp/large-test.zip --porcelain; echo "exit code: $?" 5 exit code: 0 The filter that changes the label and nothing else
You will find snippets that add an upload_size_limit filter and claim to raise the limit. They change what wp_max_upload_size() returns, which is what the media library prints — and nothing else. PHP still aborts at upload_max_filesize, so the user sees a generous number, picks a large file, and gets a generic failure with no explanation.
// Do not use this to raise the ceiling. It only changes the number on screen. add_filter( 'upload_size_limit', function ( $bytes ) { return 512 * MB_IN_BYTES; } ); The filter is still useful in the other direction: return a smaller number to stop a role from filling the disk, or to keep a staging site from accepting 200 MB imports. Just never use it to advertise a limit the server will not honour — the mismatch is invisible until someone tries it.
A useful follow-up: register a site_status_tests entry that fails when post_max_size is smaller than upload_max_filesize, or when memory_limit sits below post_max_size. Site Health then reports the mismatch on every site you manage, instead of waiting for a client to hit it halfway through a theme upload.
Originally published on WP Tuts Hub.
