Images and Media API

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

This page is the developer’s view of images in WordPress: how a plugin registers image sizes, prints responsive <img> tags, creates attachments from uploaded or downloaded files, reads and extends attachment metadata, and edits images with WP_Image_Editor. The site owner’s view (the Media screens, allowed types, big-image scaling, the 7.1 client-side processing, upload limits and alt text) is in Media Library, and the loading attributes that core adds by itself are described from the performance angle in Performance and Caching. Responsive images, formats and lazy loading outside WordPress are covered in Media Optimization for the Web.

The page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing on this page is announced to change with it. Every example was run in the official wordpress:7.1.3-php8.4-apache image (PHP 8.4, Imagick with ImageMagick 7.1, and GD) with MariaDB 11.8 and WP-CLI inside the web container, so it uses the same image libraries as uploads. The code belongs to the lumen-catalog plugin, whose wp lumen import fetches book covers.

The functions at a glance

An image attachment is a post of type attachment with three meta keys (_wp_attached_file, _wp_attachment_metadata, _wp_attachment_image_alt), as described in Media Library. Code reads them through functions:

Function Returns

wp_get_attachment_image( $id, $size, $icon, $attr )

A complete <img> tag with width, height, srcset, sizes and loading attributes

wp_get_attachment_image_src() / wp_get_attachment_image_url()

[ url, width, height, is_intermediate ] / the URL of one size

wp_get_attachment_url() / get_attached_file()

URL and path of the "full" file (the -scaled copy for big images)

wp_get_original_image_path() / wp_get_original_image_url()

The untouched upload, before scaling or format conversion

wp_get_attachment_metadata() / wp_update_attachment_metadata()

Read and write the metadata array (both filtered)

get_post_thumbnail_id() / set_post_thumbnail()

The featured image of a post (its _thumbnail_id meta)

The featured image of an lp_book is its cover. An <img> built from wp_get_attachment_url() would lose srcset, the dimensions that prevent layout shift and every loading attribute.

Registering image sizes

add_image_size( $name, $width, $height, $crop ) (2.9) adds a size that WordPress generates for every image uploaded afterwards. A width or height of 0 means unconstrained. The crop argument decides how the image fits the box:

$crop Result

false (default)

Soft: scaled to fit inside the box. A 1600 × 2400 cover in a 300 × 300 box becomes 200 × 300.

true

Hard: scaled to cover the box and cropped to exactly its size, keeping the centre.

array( 'center', 'top' )

Hard, keeping the given position: x is left, center or right; y is top, center or bottom.

WordPress never upscales: a size is made only when the original is larger than the box. set_post_thumbnail_size() registers post-thumbnail. Sizes live in a global array, so they are registered on every request (after_setup_theme). Covers are 2:3 portraits shown at 600 px on the book page and 300 px on cards. The plugin registers both, offers the larger one in the editor’s resolution control, and skips the two very large core sizes for covers:

wp-content/plugins/lumen-catalog/includes/media.php
<?php
defined( 'ABSPATH' ) || exit;

// 600 x 900 repeats the site's must-use plugin so the plugin works without it; re-registering is harmless.
function lumen_catalog_image_sizes() {
	add_image_size( 'lp-book-cover', 600, 900, true );
	add_image_size( 'lp-book-cover-small', 300, 450, array( 'center', 'top' ) );
}
add_action( 'after_setup_theme', 'lumen_catalog_image_sizes' );

// Offers the large cover size in the editor's "Resolution" control.
function lumen_catalog_size_names( $sizes ) {
	$sizes['lp-book-cover'] = __( 'Book cover', 'lumen-catalog' );
	return $sizes;
}
add_filter( 'image_size_names_choose', 'lumen_catalog_size_names' );

// A cover is an image attached to a book.
function lumen_catalog_is_cover( $attachment_id ) {
	$parent = wp_get_post_parent_id( $attachment_id );
	return $parent && 'lp_book' === get_post_type( $parent );
}

// Covers never appear wider than 600 px: skip the two very large core sizes.
function lumen_catalog_skip_cover_sizes( $sizes, $image_meta, $attachment_id ) {
	if ( lumen_catalog_is_cover( $attachment_id ) ) {
		unset( $sizes['1536x1536'], $sizes['2048x2048'] );
	}
	return $sizes;
}
add_filter( 'intermediate_image_sizes_advanced', 'lumen_catalog_skip_cover_sizes', 10, 3 );
// The 7.1 client-side path asks for missing sizes through this filter instead.
add_filter( 'wp_get_missing_image_subsizes', 'lumen_catalog_skip_cover_sizes', 10, 3 );

Two filters are needed because the upload paths ask different questions: wp_create_image_subsizes() on the server filters the list through intermediate_image_sizes_advanced ($attachment_id since 5.3), while the REST API behind the 7.1 client-side processing asks wp_get_missing_image_subsizes(); with only the first filter, the cover imported below reports 1536x1536, 2048x2048 as missing. post_parent is set before the sizes are made when a file is uploaded or sideloaded to a book; an image uploaded in the Library and chosen as featured image later gets every size. WP-CLI 2.12’s wp media image-size prints a crop-position array as soft, although the files are cropped to exactly 300 × 450.

Printing images: srcset, sizes and sizes="auto"

wp_get_attachment_image() adds, in order: src, width, height, class (attachment-<size> size-<size>) and alt; the loading attributes; srcset and sizes; auto in sizes for lazy images; then it runs the wp_get_attachment_image_attributes filter. Attributes in $attr win, and false for loading or fetchpriority removes the attribute.

srcset (4.4) comes from wp_calculate_image_srcset(): every generated file of the same image whose aspect ratio matches the requested size within one pixel, including "full", but not files wider than max_srcset_image_width (2048 px) unless that file is the src. A cover with no other 2:3 file gets no srcset. sizes comes from wp_calculate_image_sizes(), whose default (max-width: <width>px) 100vw, <width>px is right only for an image as wide as its column. A cover in a two-column grid needs its own value (in $attr, or for all images through the wp_calculate_image_sizes filter), or phones download a file twice as wide as needed.

wp-content/plugins/lumen-catalog/includes/media.php (continued)
// Returns the cover of a book as an <img> tag, or ''. $attr can add 'loading', 'fetchpriority', etc.
function lumen_catalog_cover_html( $book_id, $size = 'lp-book-cover-small', array $attr = array() ) {
	$attachment_id = get_post_thumbnail_id( $book_id );
	if ( ! $attachment_id ) {
		return '';
	}
	// Cards are two per row on phones and 300 px wide from 700 px up.
	$defaults = array( 'sizes' => '(max-width: 700px) 45vw, 300px', 'class' => 'lp-book-card__cover' );
	if ( '' === get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) {
		/* translators: %s: book title. */
		$defaults['alt'] = sprintf( __( 'Cover of %s', 'lumen-catalog' ), get_the_title( $book_id ) );
	}
	return wp_get_attachment_image( $attachment_id, $size, false, array_merge( $defaults, $attr ) );
}

The render.php of lumen/latest-books calls it in its WP_Query loop (see Static and Dynamic Blocks). For "The Lighthouse Ledger" (book 6, cover 7) the card call returns (line breaks added, upload URLs shortened to …/):

wp eval 'echo lumen_catalog_cover_html( 6 );' --url=https://lumenpress.test
<img width="300" height="450" src="…/2026/10/978-1-23456-789-7-300x450.jpg" class="lp-book-card__cover"
     alt="Cover of The Lighthouse Ledger" sizes="auto, (max-width: 700px) 45vw, 300px" decoding="async" loading="lazy"
     srcset="…/978-1-23456-789-7-300x450.jpg 300w, …/978-1-23456-789-7-200x300.jpg 200w,
             …/978-1-23456-789-7-683x1024.jpg 683w, …/978-1-23456-789-7-768x1152.jpg 768w,
             …/978-1-23456-789-7-600x900.jpg 600w, …/978-1-23456-789-7.jpg 1600w" />

The src file comes first, the 150 × 150 thumbnail is missing (other ratio), and the 1600 px "full" file is in (not wider than 2048 px). The single-book template passes lp-book-cover, (max-width: 700px) 90vw, 600px, fetchpriority => 'high' and loading => false, and gets no loading and therefore no auto.

For a lazy image the browser knows the rendered width when the download starts, so sizes="auto" lets it pick the candidate from the real layout. Since 6.7, wp_get_attachment_image() and the content filters (wp_img_tag_add_auto_sizes()) prepend auto, when the image has loading="lazy", a width and a sizes, and auto is not already first. Browsers without support skip it, so the explicit value still matters. Since 6.7.1 core also prints a contain-intrinsic-size rule for such images (the wp-img-auto-sizes-contain style since 6.9). Returning false from the wp_img_tag_add_auto_sizes filter (6.7.1) removes both.

Loading attributes and their context

wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) (6.3) decides loading, fetchpriority and decoding; the rules (threshold of three, 50,000-pixel minimum) are in Performance and Caching. For code, the context and the place of the call matter. The same 600 × 900 image, outside the main loop:

wp eval '$a = array( "width" => 600, "height" => 900 );
foreach ( array( "wp_get_attachment_image", "template_part_header", "template" ) as $c ) {
  echo str_pad( $c, 24 ), json_encode( wp_get_loading_optimization_attributes( "img", $a, $c ) ), "\n"; }'
wp_get_attachment_image {"decoding":"async","loading":"lazy"}
template_part_header    {"decoding":"async","fetchpriority":"high"}
template                []
  • Only images in the main query’s loop count against the threshold. An image in a secondary query, such as the one in lumen/latest-books, is lazy unless it is in the header template part or before the loop of a classic template, so a cover above the fold needs fetchpriority => 'high' or loading => false from the caller.

  • Without both width and height only decoding is added.

  • fetchpriority="low" (7.0) marks initially hidden images (navigation overlay, later carousel slides) and keeps them eager; fetchpriority="auto" (7.0), added by the block visibility support, keeps an image out of the count.

  • An image printed while the_content is being filtered (a shortcode, a block) gets no attributes in its own call; the content filter adds them later, in document order with the other content images.

  • Filters: wp_get_attachment_image_context, pre_wp_get_loading_optimization_attributes (short-circuit) and wp_get_loading_optimization_attributes.

Creating attachments from code

Three functions in wp-admin/includes/media.php turn a file into an attachment with its sizes and return the ID or a WP_Error. Outside admin screens, code first requires media.php, file.php and image.php from that folder.

Function Use

media_handle_upload( $file_id, $post_id, $post_data, $overrides ) (2.5)

A file just posted in a form; $file_id is its key in $_FILES. The handler with its capability and nonce checks is in Security for Developers.

media_handle_sideload( $file_array, $post_id, $desc, $post_data ) (2.6)

A file already on the server, as a $_FILES-style array with name and tmp_name. The temporary file is moved, not copied.

media_sideload_image( $url, $post_id, $desc, $return_type ) (2.6)

An image at a URL: download_url(), then media_handle_sideload(). Returns HTML by default, the URL with 'src', the ID with 'id' (4.8). Stores the URL in _source_url (5.4).

All three run the upload checks of wp_handle_upload() / wp_handle_sideload(), file the upload under the parent post’s date (media_handle_upload() makes an exception for pages), so a cover attached to a book published in 2024 lands in uploads/2024/<month>/, and take the title, caption and (since 7.0, through wp_get_image_alttext()) alt text embedded in the image unless $desc or $post_data overrides them. Covers delivered as files on disk use media_handle_sideload() with a copy, because it moves tmp_name away:

$tmp = wp_tempnam( $source ); // $source: for example ABSPATH . 'import/978-1-23456-791-0.jpg'.
copy( $source, $tmp );
$attachment_id = media_handle_sideload( array( 'name' => wp_basename( $source ), 'tmp_name' => $tmp ), $book_id, $title );
if ( is_wp_error( $attachment_id ) ) {
	@unlink( $tmp ); // Left behind on failure.
}

Importing a cover from a URL in wp lumen import

The import function fires lumen_catalog_book_imported after each new book (see Hooks: Actions and Filters). The distributor’s rows carry a cover_url; the plugin downloads it, makes it the featured image, and on a re-run reuses the attachment it already made from the same URL:

wp-content/plugins/lumen-catalog/includes/cover-import.php
<?php
defined( 'ABSPATH' ) || exit;

// Downloads a cover and makes it the featured image of a book. Returns the attachment ID or a WP_Error.
function lumen_catalog_sideload_cover( $book_id, $url ) {
	$url = esc_url_raw( $url, array( 'https', 'http' ) );
	if ( '' === $url ) {
		return new WP_Error( 'lumen_catalog_cover_url', __( 'Invalid cover URL.', 'lumen-catalog' ) );
	}

	// No duplicates on a re-run: media_sideload_image() records the URL in _source_url.
	$existing = get_posts(
		array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids', 'numberposts' => 1,
			'meta_key' => '_source_url', 'meta_value' => $url )
	);
	if ( $existing ) {
		set_post_thumbnail( $book_id, $existing[0] );
		return $existing[0];
	}

	require_once ABSPATH . 'wp-admin/includes/media.php';
	require_once ABSPATH . 'wp-admin/includes/file.php';
	require_once ABSPATH . 'wp-admin/includes/image.php';

	$title         = get_the_title( $book_id );
	$attachment_id = media_sideload_image( $url, $book_id, $title, 'id' );
	if ( is_wp_error( $attachment_id ) ) {
		return $attachment_id;
	}

	/* translators: %s: book title. */
	update_post_meta( $attachment_id, '_wp_attachment_image_alt', sprintf( __( 'Cover of %s', 'lumen-catalog' ), $title ) );
	set_post_thumbnail( $book_id, $attachment_id );

	return $attachment_id;
}

function lumen_catalog_import_cover( $book_id, $row ) {
	if ( empty( $row['cover_url'] ) ) {
		return;
	}
	$result = lumen_catalog_sideload_cover( $book_id, $row['cover_url'] );
	if ( is_wp_error( $result ) && defined( 'WP_CLI' ) && WP_CLI ) {
		WP_CLI::warning( sprintf( 'Cover for book %d: %s', $book_id, $result->get_error_message() ) );
	}
}
add_action( 'lumen_catalog_book_imported', 'lumen_catalog_import_cover', 10, 2 );

media_sideload_image() accepts only URL paths ending in .jpg, .jpeg, .jpe, .png, .gif or .webp; an AVIF or extension-less URL fails with "Invalid image URL." The image_sideload_extensions filter (5.6) adds avif; extension-less URLs need download_url() plus media_handle_sideload() with a chosen name.

download_url() uses wp_safe_remote_get(), which refuses hosts on private or loopback addresses. In the Docker stack the distributor mock (covers.distributor.example) is a container on a private address, so the first run printed Warning: Cover for book 5: A valid URL was not provided. On local and staging sites only, a must-use plugin allows exactly that host; production talks to the public distributor and does not have it:

wp-content/mu-plugins/lumen-local-distributor.php (local and staging only)
<?php
/* Plugin Name: Lumen Local Distributor (local and staging only) */
defined( 'ABSPATH' ) || exit;

// Allow exactly the mock's host, never all private hosts.
function lumen_local_distributor_host( $external, $host ) {
	return $external || 'covers.distributor.example' === $host;
}
add_filter( 'http_request_host_is_external', 'lumen_local_distributor_host', 10, 2 );

With it, the import rows for "The Lighthouse Ledger" and "The Glass Orchard" created books 6 and 8 with covers 7 and 9, each with its alt text, and a second call for book 6 with the same URL returned 7 and created nothing. The command is in WP-CLI, the HTTP API in HTTP and Filesystem APIs.

Attachment metadata

wp_generate_attachment_metadata( $id, $file ) (wp-admin/includes/image.php) builds the metadata: for images it scales big originals, creates the sub-sizes and reads EXIF/IPTC; for PDFs it renders previews; for audio and video it reads length and codecs. Uploads save it with wp_update_attachment_metadata(), and code that replaces a file in place calls the same pair. For cover 7 (wp eval over wp_get_attachment_metadata( 7 )):

2026/10/978-1-23456-789-7.jpg 1600x2400 filesize 88037
  medium               978-1-23456-789-7-200x300.jpg             200x300  image/jpeg
  large                978-1-23456-789-7-683x1024.jpg            683x1024 image/jpeg
  thumbnail            978-1-23456-789-7-150x150.jpg             150x150  image/jpeg
  medium_large         978-1-23456-789-7-768x1152.jpg            768x1152 image/jpeg
  lp-book-cover        978-1-23456-789-7-600x900.jpg             600x900  image/jpeg
  lp-book-cover-small  978-1-23456-789-7-300x450.jpg             300x450  image/jpeg
parent: 6, _source_url: http://covers.distributor.example/covers/978-1-23456-789-7.jpg

The array holds width, height, file (relative to the uploads directory), filesize (6.0), sizes (each with file, width, height, mime-type, filesize), image_meta (camera, caption, copyright, keywords, orientation, alt) and, after scaling, rotation or conversion, original_image. The REST API exposes it as media_details; see REST API: Using It.

Extending metadata: wp_generate_attachment_metadata

The wp_generate_attachment_metadata filter receives the metadata, the attachment ID and (5.3) a context: 'create' for a new file, 'update' when wp_update_image_subsizes() completes the sizes later, after a timed-out upload or in the finalize step of the 7.1 client-side upload, which fires it a second time for the same file. It also runs on every wp media regenerate. Callbacks must therefore be idempotent, and must always return the array: returning nothing erases the metadata, and the image loses its sizes and srcset. Lumen Press flags covers too small for 600 × 900:

wp-content/plugins/lumen-catalog/includes/cover-checks.php
<?php
defined( 'ABSPATH' ) || exit;

// Flags covers too small for the 600 x 900 size, so the catalogue team can ask for a better file.
function lumen_catalog_check_cover( $metadata, $attachment_id, $context ) {
	if ( ! lumen_catalog_is_cover( $attachment_id ) || empty( $metadata['width'] ) ) {
		return $metadata;
	}
	// Idempotent: the same file always leaves the same meta, whichever pass runs.
	if ( $metadata['width'] < 600 || $metadata['height'] < 900 ) {
		update_post_meta( $attachment_id, 'lp_cover_too_small', 1 );
	} else {
		delete_post_meta( $attachment_id, 'lp_cover_too_small' );
	}
	return $metadata;
}
add_filter( 'wp_generate_attachment_metadata', 'lumen_catalog_check_cover', 10, 3 );

Importing "The Quiet Lighthouse", whose cover is only 400 × 600, with the WebP mapping from Output formats: image_editor_output_format active, then printing its metadata and flag:

book 12, cover 13: 2026/10/978-1-23456-791-0.webp 400x600, sizes: medium, thumbnail, lp-book-cover-small
lp_cover_too_small: '1'

Keys a plugin adds to the array are lost on the next regeneration; derived values belong in post meta, as here.

Editing images with WP_Image_Editor

WP_Image_Editor (3.5) is the abstract class behind every resize, crop and rotation in core, implemented by WP_Image_Editor_Imagick and WP_Image_Editor_GD. wp_get_image_editor( $path, $args ) loads the file with the first class in the wp_image_editors filter (Imagick, then GD) that passes test(), supports the input type and has the methods in $args['methods'], preferring one that can write the mapped output format; otherwise it returns image_no_editor. wp_image_editor_supports( $args ) asks without a file.

Method Does

resize( $max_w, $max_h, $crop )

Scales into the box; $crop as in add_image_size()

multi_resize(), make_subsize() (5.3), crop(), rotate(), flip()

Several sizes from one load; region and orientation

set_quality(), get_quality(), get_size()

Compression quality (1–100) and current dimensions

save( $filename, $mime_type )

Writes the file; returns path, file, width, height, mime-type, filesize

generate_filename( $suffix, $dir, $ext )

Builds a name-<suffix>.<ext> path

On the official image both libraries handle JPEG, PNG, GIF, WebP and AVIF (GD is the bundled 2.1 with AVIF), and only Imagick reads HEIC, which is why HEIC uploads need it. wp_image_editor_supports( array( 'mime_type' => 'image/heic' ) ) returned true, and false with wp_image_editors reduced to WP_Image_Editor_GD. Imagick keeps colour profiles and allocates outside PHP’s memory_limit; GD decodes the bitmap into PHP memory at about 4 bytes per pixel (roughly 100 MB for 6000 × 4000). If ImageMagick lacks a delegate (WebP, say), core falls back to GD for that type. Tools > Site Health > Info > Media Handling shows what a server has.

A share image for each cover

Book pages announce a 1200 × 630 image to social networks (og:image). The plugin derives it from the cover on demand instead of registering a size, which every image on the site would get:

wp-content/plugins/lumen-catalog/includes/share-image.php
<?php
defined( 'ABSPATH' ) || exit;

// Creates a 1200 x 630 WebP share image from a cover and lists it as the 'lp-share' size.
function lumen_catalog_make_share_image( $attachment_id ) {
	// Start from the untouched upload, not the "-scaled" copy.
	$source = wp_get_original_image_path( $attachment_id );
	if ( ! $source ) {
		return new WP_Error( 'lumen_catalog_not_image', __( 'Not an image.', 'lumen-catalog' ) );
	}
	$editor = wp_get_image_editor( $source, array( 'methods' => array( 'resize', 'save' ) ) );
	if ( is_wp_error( $editor ) ) {
		return $editor;
	}
	if ( ! $editor::supports_mime_type( 'image/webp' ) ) {
		return new WP_Error( 'lumen_catalog_no_webp', __( 'The image editor cannot write WebP.', 'lumen-catalog' ) );
	}

	// Hard crop, keeping the top of the cover (title and author name).
	$resized = $editor->resize( 1200, 630, array( 'center', 'top' ) );
	if ( is_wp_error( $resized ) ) {
		return $resized;
	}

	// set_quality( 80 ) would be reset when save() converts to WebP, so filter the quality instead.
	$quality = static function ( $value, $mime_type ) {
		return 'image/webp' === $mime_type ? 80 : $value;
	};
	add_filter( 'wp_editor_set_quality', $quality, 10, 2 );
	$saved = $editor->save( $editor->generate_filename( 'share', null, 'webp' ), 'image/webp' );
	remove_filter( 'wp_editor_set_quality', $quality, 10 );
	if ( is_wp_error( $saved ) ) {
		return $saved;
	}

	// Listed under 'sizes', the file is deleted with the attachment and found by wp_get_attachment_image_url().
	$meta                      = wp_get_attachment_metadata( $attachment_id );
	$meta['sizes']['lp-share'] = array(
		'file'      => $saved['file'],
		'width'     => $saved['width'],
		'height'    => $saved['height'],
		'mime-type' => $saved['mime-type'],
	);
	wp_update_attachment_metadata( $attachment_id, $meta );

	return $saved;
}

For cover 7 it returns 978-1-23456-789-7-share.webp (1200 × 630, image/webp), and wp_get_attachment_image_url( 7, 'lp-share' ) returns its URL; its ratio keeps it out of srcset. A full wp media regenerate drops the entry (the file stays), so callers recreate it when the URL comes back empty.

The quality detail is a real trap: when save() converts to another format, the editor resets the quality to that format’s default (82 for JPEG, 86 for WebP) or the wp_editor_set_quality value, discarding an earlier set_quality(). Saving the same crop with set_quality( 80 ) wrote quality 86 (3,262 bytes); with the filter, quality 80 (2,734 bytes).

Output formats: image_editor_output_format

The image_editor_output_format filter (5.8) maps an input MIME type to the type the editor writes; its default since 6.7 maps HEIC and HEIF to JPEG. The mapping applies to every save of that input type: every sub-size and a converted "full" copy, with the uploaded file then named in original_image, while the attachment’s post_mime_type keeps the uploaded type. The site-owner side, with an AVIF example, is in Media Library. Lumen Press writes WebP and lowers the quality of small sizes with the $size argument that wp_editor_set_quality gained in 6.8:

wp-content/mu-plugins/lumen-image-formats.php
<?php
/* Plugin Name: Lumen Image Formats (WebP sub-sizes; requires WordPress 6.8) */
defined( 'ABSPATH' ) || exit;

// Write JPEG uploads as WebP. Ignored by an editor that cannot write WebP.
function lumen_image_formats_map( $formats ) {
	$formats['image/jpeg'] = 'image/webp';
	return $formats;
}
add_filter( 'image_editor_output_format', 'lumen_image_formats_map' );

// The WebP default is 86: use 82, and 70 for sizes up to 300 px wide.
function lumen_image_formats_quality( $quality, $mime_type, $size = array() ) {
	if ( 'image/webp' !== $mime_type ) {
		return $quality;
	}
	return ( isset( $size['width'] ) && $size['width'] <= 300 ) ? 70 : 82;
}
add_filter( 'wp_editor_set_quality', 'lumen_image_formats_quality', 10, 3 );

wp media regenerate 9 --yes on "The Glass Orchard" cover then produced a metadata file of 2026/10/978-1-23456-790-3.webp, every size as image/webp (862 bytes for 300 × 450, 2,930 for 600 × 900) and original_image set to the JPEG. An editor that cannot write the mapped type keeps the input format, so the filter needs no support check. The mapping also applies to plugin code that calls save() with an explicit image/jpeg: under this filter that file comes out as WebP, with a .webp extension.

Errors and pitfalls

Symptom Cause and fix

Call to undefined function media_sideload_image() (or wp_generate_attachment_metadata()) in cron, REST or WP-CLI

The wp-admin/includes/ files load on admin screens only. Require media.php, file.php and image.php.

"A valid URL was not provided." / "Invalid image URL."

A private or loopback host refused by the safe HTTP functions / a URL path without an allowed image extension.

A new size is missing on old images

Sizes are made at upload. Run wp media regenerate --only-missing, see Media Library.

No srcset

No other file with the same ratio, metadata emptied by a filter, or a src matching no file in the metadata.

The hero cover is lazy-loaded

It is printed outside the main loop. Pass fetchpriority => 'high' or loading => false.

set_quality() ignored, or skipped sizes still made in the block editor

The save converts formats (use wp_editor_set_quality) / only intermediate_image_sizes_advanced was filtered (add wp_get_missing_image_subsizes).

Security

  • The upload functions check real content against the extension and the user’s allowed types; file_put_contents() into uploads bypasses that (see Security for Developers).

  • Downloading a URL that a user supplies is a server-side request forgery risk: stay on the safe HTTP functions, allow private hosts one at a time and only outside production, and check upload_files and a nonce first.

  • wp_get_attachment_image() escapes every attribute; hand-built markup uses esc_url() and esc_attr().

  • Sideloaded originals keep EXIF data, including GPS; generated sizes drop most of it (image_strip_meta).

  • An endpoint that resizes on request (the old "timthumb" pattern) invites denial of service; generate files once.

Where older books and tutorials are outdated

Older advice Current practice

Resize with image_resize() or wp_load_image()

Both deprecated since 3.5; use wp_get_image_editor().

Print <img src="<?php echo wp_get_attachment_url( $id ); ?>">, common in classic-theme tutorials

wp_get_attachment_image() or the_post_thumbnail(), which add dimensions, srcset, sizes and loading attributes.

Lazy-load with a plugin, a script or a filter for iframes (Ultimate WordPress Handbook)

Core lazy-loads images since 5.5 and iframes since 5.7, adds fetchpriority since 6.3 and sizes="auto" since 6.7.

media_sideload_image() returns an <img> tag

Pass 'id' as the fourth argument (4.8) to get the ID.