Handling File Uploads
Route uploads through wp_handle_upload and confirm the real type with wp_check_filetype_and_ext — the extension proves nothing.
wp_handle_upload( array $file, array|false $overrides = false, string|null $time = null ): arrayAn upload is a filename, a declared MIME type and a stream of bytes, all three chosen by whoever sent the request. WordPress will refuse a file whose extension is not on its allowlist, but only if you route the upload through its handlers rather than calling move_uploaded_file() yourself.
wp_handle_upload
wp_handle_upload( array $file, array|false $overrides = false, string|null $time = null ) takes one element of $_FILES, validates it, moves it into the uploads directory under a unique name, and returns array( 'file' => path, 'url' => url, 'type' => mime ) — or an array with an 'error' key. Success is the absence of that key.
It lives in wp-admin/includes/file.php, which is not loaded on the front end, so require_once ABSPATH . 'wp-admin/includes/file.php' first. By default it also requires that $_POST['action'] match $overrides['action'] (which defaults to 'wp_handle_upload'); pass 'test_form' => false when your form posts a different action.
Read the bytes, not the name
wp_check_filetype() only inspects the filename. wp_check_filetype_and_ext( $file, $filename, $mimes = null ) opens the file and returns ext, type and proper_filename — the last being set when the real content disagrees with the name and the file should be renamed. That is what keeps shell.php renamed to shell.jpg from being stored as PHP.
For anything belonging in the Media Library, media_handle_upload( $file_id, $post_id, $post_data = array(), $overrides = array( 'test_form' => false ) ) uploads and creates the attachment post, returning an int or WP_Error.
Example
<?php
add_action( 'admin_post_acme_upload_logo', 'acme_handle_logo_upload' );
function acme_handle_logo_upload() {
check_admin_referer( 'acme_upload_logo' );
if ( ! current_user_can( 'upload_files' ) ) {
wp_die( esc_html__( 'Not allowed.', 'acme' ), '', array( 'response' => 403 ) );
}
if ( empty( $_FILES['acme_logo']['name'] ) ) {
wp_die( esc_html__( 'No file received.', 'acme' ), '', array( 'response' => 400 ) );
}
// Not loaded outside the media screens — include it before calling.
require_once ABSPATH . 'wp-admin/includes/file.php';
$file = $_FILES['acme_logo'];
// Read the file itself. $file['type'] came from the browser and means nothing.
$check = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
if ( ! $check['type'] || ! str_starts_with( $check['type'], 'image/' ) ) {
wp_die( esc_html__( 'That file is not an image.', 'acme' ), '', array( 'response' => 400 ) );
}
// Set when the content and the name disagree — use the corrected name.
if ( $check['proper_filename'] ) {
$file['name'] = $check['proper_filename'];
}
$result = wp_handle_upload( $file, array(
'test_form' => false, // our form posts action=acme_upload_logo, not wp_handle_upload
'mimes' => array( // narrow the allowlist further for this one upload
'jpg|jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
),
) );
// Failure is an 'error' key, not false and not WP_Error.
if ( isset( $result['error'] ) ) {
wp_die( esc_html( $result['error'] ) );
}
// $result['file'] = absolute path, ['url'] = public URL, ['type'] = mime.
update_option( 'acme_logo_url', $result['url'] );
wp_safe_redirect( admin_url( 'options-general.php?page=acme&uploaded=1' ) );
exit;
}When to use it
- Accepting a logo or header image from a plugin settings page
- Building a front-end form that adds a submitted photo to the Media Library
- Restricting one specific upload endpoint to a narrower list of types than the site allows
- Working out why an upload is rejected as an invalid type when the extension looks correct
More examples
Into the Media Library with media_handle_upload
wp_handle_upload() only puts bytes on disk; nothing in the admin knows the file exists. media_handle_upload() runs the same upload and then creates the attachment post, generates the intermediate image sizes and returns the attachment ID, so the file appears in the Media Library and can be attached to a post. Note it needs three admin includes, and that its default $overrides already sets test_form to false.
<?php
function acme_accept_submission_image( $post_id ) {
if ( empty( $_FILES['acme_image']['name'] ) ) {
return 0;
}
// media_handle_upload() needs all three on the front end.
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
$attachment_id = media_handle_upload(
'acme_image', // the $_FILES key, as a string
$post_id, // attach to this post; 0 leaves it unattached
array( 'post_title' => sanitize_text_field( wp_unslash( $_POST['acme_caption'] ?? '' ) ) )
// $overrides defaults to array( 'test_form' => false )
);
if ( is_wp_error( $attachment_id ) ) {
// Unlike wp_handle_upload(), this one really does return WP_Error.
error_log( 'Upload failed: ' . $attachment_id->get_error_message() );
return 0;
}
set_post_thumbnail( $post_id, $attachment_id );
return $attachment_id;
}Widening or narrowing the allowed types
get_allowed_mime_types() returns an array keyed by an extension regex, and the upload_mimes filter is how you change it site-wide. Widening it is a real risk decision, not a formality: WordPress excludes SVG by default because an SVG is a document that can carry script, so allowing it without sanitizing the file means storing executable content. Narrowing per-upload via the 'mimes' override, as in the primary example, is usually the safer move.
<?php
// Inspect what the current user may upload.
$allowed = get_allowed_mime_types(); // array( 'jpg|jpeg|jpe' => 'image/jpeg', ... )
// Add a type site-wide, and only for users who can already manage the site.
add_filter( 'upload_mimes', function ( $mimes, $user ) {
if ( user_can( $user, 'manage_options' ) ) {
$mimes['csv'] = 'text/csv';
}
return $mimes;
}, 10, 2 );
// Remove a type site-wide.
add_filter( 'upload_mimes', function ( $mimes ) {
unset( $mimes['gz|gzip'], $mimes['zip'] );
return $mimes;
} );
// SVG is excluded by default for a reason: it can contain <script>. Do not add
// it with a one-line filter — an SVG upload path needs the file content
// sanitized before it is stored, not just an entry in this array.Checking the PHP-level error before anything else
Before WordPress sees the file at all, PHP may have rejected it — a POST larger than post_max_size arrives with an empty $_FILES, and an oversized single file arrives with UPLOAD_ERR_INI_SIZE. Reporting these as "invalid file type" is the classic misleading upload bug. Check $file['error'] first and surface the real limits, which wp_max_upload_size() reports as the smaller of the two PHP settings.
<?php
$file = $_FILES['acme_logo'] ?? null;
// An empty $_FILES with a non-empty POST usually means post_max_size was exceeded.
if ( null === $file && ! empty( $_SERVER['CONTENT_LENGTH'] ) ) {
wp_die( sprintf(
/* translators: %s: human-readable size */
esc_html__( 'The upload exceeded the server limit of %s.', 'acme' ),
esc_html( size_format( wp_max_upload_size() ) )
) );
}
if ( UPLOAD_ERR_OK !== $file['error'] ) {
$messages = array(
UPLOAD_ERR_INI_SIZE => __( 'File is larger than the server allows.', 'acme' ),
UPLOAD_ERR_FORM_SIZE => __( 'File is larger than the form allows.', 'acme' ),
UPLOAD_ERR_PARTIAL => __( 'The upload was interrupted. Try again.', 'acme' ),
UPLOAD_ERR_NO_FILE => __( 'No file was selected.', 'acme' ),
UPLOAD_ERR_NO_TMP_DIR => __( 'Server misconfiguration: no temp directory.', 'acme' ),
UPLOAD_ERR_CANT_WRITE => __( 'Server could not write the file.', 'acme' ),
);
$message = $messages[ $file['error'] ] ?? __( 'Upload failed.', 'acme' );
wp_die( esc_html( $message ), '', array( 'response' => 400 ) );
}
// Only now is it worth checking the type.
Discussion