Skip to content
ThemesIonic — home
WordPress Tutorials

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.

5 min read advanced

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 path without url. The file lands in the new folder and wp_get_attachment_url() finds it, but the upload response and the attachment's GUID are built from url and point to the old folder. Plugins that read the GUID then link to a missing file.
  • Changing basedir/baseurl only 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 basedir already 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.

Related guides