Attachments in Code
Create attachments programmatically with wp_insert_attachment, then build their thumbnails with wp_generate_attachment_metadata.
wp_insert_attachment( array $args, string|false $file = false, int $parent_post_id = 0, bool $wp_error = false ): int|WP_ErrorImporters, REST endpoints and CLI scripts all need to put a file into the Media Library without a browser upload. That takes two steps, and skipping the second is the most common bug in the whole area.
Insert, then generate
wp_insert_attachment( $args, $file, $parent_post_id, $wp_error, $fire_after_hooks ) creates the post row. Pass post_mime_type, post_title, post_content and post_status in $args, and the absolute path as $file. It returns the new ID, or 0 on failure — or a WP_Error if you set $wp_error to true.
At that point no thumbnails exist. wp_generate_attachment_metadata( $attachment_id, $file ) reads the file, creates every registered size, and returns the metadata array, which you then store with wp_update_attachment_metadata(). Only the first is admin-only: it lives in wp-admin/includes/image.php, so a front-end or cron context must require_once that file first. wp_update_attachment_metadata() lives in wp-includes/post.php and is always loaded.
Sideloading a remote image
For a file on another server, download_url( $url, $timeout, $signature_verification ) fetches it to a temporary path and returns that path or a WP_Error. Hand it to media_handle_sideload( $file_array, $post_id, $desc, $post_data ) with name and tmp_name keys; it validates the type, moves the file and does both steps above for you. media_sideload_image( $file, $post_id, $desc, $return_type ) wraps that in one call — pass 'id' for the attachment ID, since the default $return_type is 'html'.
Example
<?php
/**
* Attach a file that already sits on disk to a post.
*
* @param string $path Absolute path inside the uploads folder.
* @param int $parent_id Post to attach it to, or 0.
* @return int|WP_Error Attachment ID on success.
*/
function scx_attach_local_file( string $path, int $parent_id = 0 ) {
if ( ! file_exists( $path ) ) {
return new WP_Error( 'scx_missing_file', 'File not found: ' . $path );
}
// Detect the MIME type through WordPress so blocked types are rejected.
$filetype = wp_check_filetype( basename( $path ), null );
if ( empty( $filetype['type'] ) ) {
return new WP_Error( 'scx_bad_type', 'Disallowed file type.' );
}
$attachment_id = wp_insert_attachment(
array(
'post_mime_type' => $filetype['type'],
'post_title' => sanitize_text_field( pathinfo( $path, PATHINFO_FILENAME ) ),
'post_content' => '',
'post_status' => 'inherit', // attachments always use 'inherit'
),
$path,
$parent_id,
true // return WP_Error rather than 0 on failure
);
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
// Thumbnails do NOT exist yet. wp_generate_attachment_metadata() lives in
// wp-admin/includes/image.php, which is not loaded on the front end or
// during cron. (wp_update_attachment_metadata() is in wp-includes/post.php
// and needs no include.)
require_once ABSPATH . 'wp-admin/includes/image.php';
$metadata = wp_generate_attachment_metadata( $attachment_id, $path );
wp_update_attachment_metadata( $attachment_id, $metadata );
return $attachment_id;
}When to use it
- Import a supplier's product images into the Media Library from a nightly feed.
- Attach a generated PDF invoice to an order post from a REST endpoint.
- Set a downloaded image as a post's featured image during a migration.
- Rebuild missing thumbnails for attachments whose metadata was lost.
More examples
Sideloading a remote image with proper cleanup
This is the shape every feed importer and product syncer ends up with. media_handle_sideload() handles MIME validation, the move into uploads, and metadata generation, so you only own the download and the cleanup. Note that the _source_url post meta recording where an image came from is written by media_sideload_image() (added in WordPress 5.4), not by media_handle_sideload() — in this longhand you must add it yourself with add_post_meta( $attachment_id, '_source_url', $url ) if you want to skip re-downloading an image you already have.
/**
* Download a remote image and add it to the library.
*
* @return int|WP_Error Attachment ID.
*/
function scx_sideload_image( string $url, int $post_id, string $desc = '' ) {
// download_url() and media_handle_sideload() are admin-only includes.
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$tmp = download_url( $url, 30 ); // default timeout is 300 seconds
if ( is_wp_error( $tmp ) ) {
return $tmp;
}
$file_array = array(
'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ),
'tmp_name' => $tmp,
);
$attachment_id = media_handle_sideload( $file_array, $post_id, $desc );
// On failure the temp file is still on disk. Remove it.
if ( is_wp_error( $attachment_id ) ) {
@unlink( $tmp );
return $attachment_id;
}
return $attachment_id;
}The one-call shorthand and the longhand it replaces
media_sideload_image() is a wrapper around download_url() plus media_handle_sideload(); the two blocks produce the same attachment. Use the shorthand for one-off imports, and drop to the longhand when you need to inspect the temporary file first, rename it, or pass extra $post_data such as a specific post_author.
// Shorthand: one call does the download, the move and the metadata.
// $return_type defaults to 'html', so pass 'id' when you want the ID.
$attachment_id = media_sideload_image(
'https://example.com/photo.jpg',
$post_id,
'Product photo',
'id'
);
// Longhand: exactly the same result, spelled out.
$tmp = download_url( 'https://example.com/photo.jpg' );
$file_array = array( 'name' => 'photo.jpg', 'tmp_name' => $tmp );
$attachment_id = media_handle_sideload( $file_array, $post_id, 'Product photo' );
// Either way, set it as the featured image afterwards.
if ( ! is_wp_error( $attachment_id ) ) {
set_post_thumbnail( $post_id, $attachment_id );
}Deleting an attachment and its files
Unlinking the file yourself leaves an orphan post row and stale metadata; wp_delete_attachment() removes the row, the meta, the original and every subsize. The delete_attachment hook, which passes the WP_Post as a second argument, is where you clear caches or external CDN copies while the record still exists.
// Second argument true forces deletion instead of moving to trash.
// This also removes every generated size from disk.
$deleted = wp_delete_attachment( $attachment_id, true );
if ( ! $deleted ) {
error_log( "Could not delete attachment {$attachment_id}" );
}
// Reacting to a deletion elsewhere: the hook fires before the row is gone,
// so the metadata is still readable here.
add_action(
'delete_attachment',
static function ( int $post_id, WP_Post $post ): void {
delete_transient( 'scx_gallery_' . $post->post_parent );
},
10,
2
);
Discussion