Using the upload_dir Filter Without Breaking Media URLs
upload_dir runs every time WordPress asks where uploads live, not only during uploads. Change subdir, path and url together and leave basedir and baseurl alone, or existing media URLs break.
upload_dir filters the array wp_upload_dir() returns, and WordPress calls that function constantly, not just during uploads. It receives one argument, an array with path, url, subdir, basedir, baseurl and error, and you must return the same array. Changing where new files go is safe if you change subdir, path and url together. Changing basedir or baseurl moves the URL of every existing file in the library, which is how most broken-media bugs from this filter start.
The array and what each key controls
| Key | Example | Used for |
|---|---|---|
basedir |
/var/www/site/wp-content/uploads |
Root that attachment paths are stored relative to |
baseurl |
https://example.com/wp-content/uploads |
Root that every attachment URL is built from |
subdir |
/2026/11 |
The part added for the current upload |
path |
basedir + subdir |
Folder the new file is written to |
url |
baseurl + subdir |
URL of that folder |
error |
false |
Message if the folder cannot be created |
The defaults come from _wp_upload_dir(). It reads the upload_path and upload_url_path options, the UPLOADS constant and the year/month setting. WordPress caches that result for the request, but apply_filters( 'upload_dir', … ) runs outside the cache, on every call. After the filter, WordPress creates path if it does not exist. If it cannot, you get Unable to create directory.
Why basedir and baseurl are off limits
When an attachment is saved, WordPress stores its file path in _wp_attached_file relative to basedir, for example 2026/11/photo.jpg. When the image is shown, wp_get_attachment_url() calls wp_get_upload_dir() again and adds the stored path to the current baseurl.
So the two roots have to stay the same between the request that saved a file and every request that shows it. If a filter sets basedir to a "documents" folder only while uploading PDFs, the PDF is saved as 2026/11/file.pdf relative to that folder. Later requests rebuild its URL from the normal baseurl, and the link points to a file that is not there. The safe approach is to leave both roots alone and add the extra folder below them, in subdir, path and url. The stored path is worked out from where the file actually lands, relative to the unchanged basedir, so the extra folder becomes part of it:
function ti_documents_folder( array $uploads ): array {
$uploads['subdir'] = '/documents' . $uploads['subdir'];
$uploads['path'] = $uploads['basedir'] . $uploads['subdir'];
$uploads['url'] = $uploads['baseurl'] . $uploads['subdir'];
return $uploads;
}
The stored path becomes documents/2026/11/file.pdf, and the URL works on every later request whether or not the filter is active.
Changing baseurl on every request is a valid choice, for example to serve uploads from a CDN hostname. Because it is applied the same way every time, nothing breaks. The breakage only happens when the roots change depending on context.
Scoping the filter to one upload
A filter added globally applies to every wp_upload_dir() call: theme code that reads the uploads URL, plugins that write caches or logs into uploads, exports, backups. To change only certain uploads, turn the filter on inside the upload and off again afterwards:
add_filter( 'wp_handle_upload_prefilter', 'ti_route_pdfs' );
add_filter( 'wp_handle_sideload_prefilter', 'ti_route_pdfs' );
function ti_route_pdfs( array $file ): array {
$type = wp_check_filetype( $file['name'] );
if ( 'application/pdf' === $type['type'] ) {
add_filter( 'upload_dir', 'ti_documents_folder' );
add_filter( 'wp_handle_upload', 'ti_documents_folder_off' );
}
return $file;
}
function ti_documents_folder_off( array $upload ): array {
remove_filter( 'upload_dir', 'ti_documents_folder' );
remove_filter( 'wp_handle_upload', 'ti_documents_folder_off' );
return $upload;
}
The prefilter runs at the start of _wp_handle_upload(), before the folder is chosen. The wp_handle_upload filter runs at the end of every successful upload or sideload, so the folder filter is removed straight away. If the upload fails, the filter stays on for the rest of that request only. That is harmless for an AJAX or REST upload request.
Reading the parent post
Many examples sort uploads by post type and read $_REQUEST['post_id']. Only the classic Media Library uploader (async-upload.php) sends that field. The block editor uploads through /wp/v2/media, and the parent arrives there as post. A filter that reads only post_id treats every block editor upload as unattached. Read both:
$parent = absint( $_REQUEST['post_id'] ?? $_REQUEST['post'] ?? 0 );
Another detail in the same code path: when an upload is attached to a post (not a page), media_handle_upload() passes the post's date as the time, so the year/month folder follows the post's publish date and not today's date. Uploading into an old post puts the file in an old month's folder. That is expected behaviour, not your filter going wrong.
Keep the callback cheap
A page that shows thirty images can call wp_upload_dir() dozens of times. Database queries, remote calls or file checks in the callback run each time. Work out anything expensive once and keep it in a static variable, or decide in the prefilter as above, where it runs once per upload.
Common mistakes
- Changing
pathwithouturl. The file lands in the new folder andwp_get_attachment_url()finds it, but the upload response and the attachment's GUID are built fromurland point to the old folder. Plugins that read the GUID then link to a missing file. - Changing
basedir/baseurlonly during uploads, which breaks URLs for those files later. - Using the filter to "protect" private uploads. The folder is still served by the web server unless a server rule blocks it. Moving files changes where they are, not who can read them.
- Forgetting multisite, where
basediralready ends in/sites/<id>. Build on what you receive rather than hard-coding paths.
Verify
- Upload a matching file and a non-matching one. Only the first lands in the custom folder.
- Check the attachment's file URL in the Media Library opens the file.
- Older media still loads on the front end.
- Run Regenerate Thumbnails on a file in the custom folder. Its sizes are written next to it. For the sizes themselves, see add_image_size and the Media Library overview.
Frequently asked
- The filter probably changed basedir or baseurl. WordPress stores each attachment's path relative to basedir and rebuilds the URL from baseurl on every request, so changing either one moves every old file's URL. Change subdir, path and url instead.
- Only the classic Media Library uploader sends post_id. The block editor uploads through the REST API, which sends the parent as post, so a filter that reads post_id treats every block editor upload as unattached. Read both, or decide inside wp_handle_upload_prefilter, where the file itself is available.
- Yes. Sub-sizes are written next to the original file, so if the original lands in the custom folder, so do its thumbnails. Regenerating thumbnails later also writes them next to the original, whatever the filter returns at that moment.