In July of 2021 Automatic released version 5.8 of WordPress which comes with native WebP Support. What that means is, that editors can now use and upload WebP images in WordPress and that these images will be handled just as WordPress does with jpeg/png.

Although there’s no good reason to keep using an older version than WordPress 5.8 by now, there is a Plugin called WebP Express, which implements WebP support and allows falling back to png/jpeg for visitors with an outdated browser. Basically this plugin allows you to continue uploading png and jpeg, and automagically converts the files into WebP. But, since WordPress now handles WebP on its own, you definitely avoid to install an additional Plugin for this purpose. As a side note, the installation of the WebP Express Plugin on Nginx is a bit tricky and requires additional server configuration.

The approach discussed here is way more minimalistic: instead of relying on a Plugin, we simply switch to uploading WebP images and convert all previously uploaded images in a single run of a small script.

What is WebP?

As Google explains here, »WebP is a modern image format that provides superior lossless and lossy compression for images on the web. Using WebP, webmasters and web developers can create smaller, richer images that make the web faster. WebP lossless images are 26% smaller in size compared to PNGs.«

WebP is developed by Google and reduces the file size of images significantly. In my experience, an ever bigger reduction of about 35-40% of original image sizes was seen.

Browser Support

All major browsers have support for WebP images. According to caniuse, WebP support is around 95-96% of all users. Nevertheless, that number might differ for your websites audience. If you can’t afford that even the tiniest percentage of your visitors don’t see images, then WebP isn’t for you. Although there a ways to provide WebP to modern browsers and jpeg or png to older browsers, I don’t think the effort is justified. This adds extra complexity to your image assets, they always need to be provided in both formats. Although this is technically possible and can be done programmatically, I consider this work to be unnecessary. Instead you could just wait another year or two and do the switch to WebP at a later point. For example when your audiences’ browsers support is above a certain percentage (for example above 99%). Remember, that in ecommerce even a minority of 5% of your visitors is a relevant group.

Start uploading WebP images

Given that WebP support of your audience is sufficient, and that you are using WordPress greater than v5.8, you can start to use WebP now. By that, I am referring to actually uploading images in WebP format (without the need for an additional image conversion plugin). Although image editing application support for WebP is still limited, there is a Photoshop extensions which allow to save images in WebP format. There is an extension to macOS that integrates WebP into Finder and with Quick Look. And there is a drag & drop application to convert from jpeg/png to WebP.

Starting WordPress 5.8 it is now possible to upload WebP images to the media library. Also, the WordPress image functions can now handle WebP images properly. By that, I am referring to these functions properly formatting images with srcset attributes for different resolutions (for example for Retina and non-Retina displays) and that WordPress handles thumbnail generation correctly.

  • WebP Photoshop Extension which basically allows the creation and conversion of images into the new WebP format
  • WebPQuickLook improves integration of the WebP format into macOS.
  • WebPonize is another simple GUI Tool that converts jpeg/png to WebP

Smashing Magazine has an article outlining many different ways to save and convert images into WebP.

But what about images uploaded in the past?

All the images you’ve been adding to your media library in the past, will remain in the old formats unless you convert them. Instead of doing this manually, image by image, we want this done automatically. Instead of adding a new plugin for this one-time conversion, I’d rather recommend to use a simple PHP script.

Let’s write a non-destructive PHP script for a one-time conversion of all existing images, which:

  1. scans your uploads folder (and any additional folder) for images
  2. create a WebP version alongside the original file
  3. updates all occurrences of the original image filenames in your database (in posts, pages and products) with the new WebP filename
  4. without the need to install a plugin, nor to re-upload images, or edit posts.
bin/image-convert.php<?phpdeclare(strict_types = 1);use WebPConvert\WebPConvert;set_time_limit(0);chdir(dirname(__DIR__)); // one level up to project root// check requirementsif (! is_file('vendor/autoload.php')) {    echo "File vendor/autoload.php not found. Is the path correct?";    exit();}require ('vendor/autoload.php');// Neither is CLI nor CLI-Server: Execution is forbidden (for HTTP). This is superuser stuffif (! in_array(php_sapi_name(), [    'cli',    'cli-server'])) {    http_response_code(403);    header('Content-Type: text/plain');    echo 'Forbidden.';    exit();}if (! extension_loaded('gd') && ! extension_loaded('imagick') && ! extension_loaded('gmagick')) {    echo "WebP is not supported. You need one at least GD or one of ImageMagick, Gmagick, cwebp, etc";    exit();}if (! class_exists('WebPConvert\WebPConvert')) {    echo "WebPConvert class not found. Run `composer require rosell-dk/webp-convert` to add it to your composer.json\n";    exit();}// recursive file search by pattern in a given folderfunction rsearch(string $folder, string $pattern): array{    $dir = new RecursiveDirectoryIterator($folder);    $ite = new RecursiveIteratorIterator($dir);    $files = new RegexIterator($ite, $pattern, RegexIterator::GET_MATCH);    $fileList = array();    foreach ($files as $file) {        $fileList = array_merge($fileList, $file);    }    return $fileList;}// find (or guess) wordpress pathfunction get_wordpress_path(): string{    if (! is_file('vendor/composer/installed.json')) {        throw new \Exception('Is your project based on composer?');    }    // find    $installed = json_decode(file_get_contents('vendor/composer/installed.json'), true);    if (isset($installed['versions']['johnpbloch/wordpress-core']['install-path'])) {        return realpath($installed['versions']['johnpbloch/wordpress-core']['install-path']);    }    // or guess    if (is_dir('wp-admin')) {        return getcwd();    }    if (is_dir('wordpress/wp-admin')) {        return getcwd() . '/wordpress';    }    return '.';}// WebP configuration$webp_options = [    'metadata' => 'none',    'encoding' => 'lossless',    'show-report' => true,    'preset' => 'photo',    'alpha-quality' => 100,    'default-quality' => 100,    'max-quality' => 100,    'quality' => 100];$wordpress_path = get_wordpress_path();// load wordpress, especially for $wpdb and functionsdefine('WP_USE_THEMES', false);require_once $wordpress_path . '/wp-load.php';require_once $wordpress_path . '/wp-admin/includes/ajax-actions.php';$paths = [    $wordpress_path . '/wp-content/uploads',    $wordpress_path . '/another/directory'];echo "Scanning folders for images...\n";$matches = [];foreach ($paths as $path) {    if (! is_dir($path)) {        printf("Path %s not found.\n", $path);        continue;    }    $files = rsearch($path, '/^.*\.(jpe?g|png|bmp|tiff)$/i');    $matches = array_merge($matches, $files);}$matches = array_filter($matches, "is_file");printf("Found %s images.\n", count($matches));$existed = 0;$converted = 0;foreach ($matches as $file) {    $pathinfo = pathinfo($file);    if (is_file($pathinfo['dirname'] . '/' . $pathinfo['filename'] . '.webp')) {         $existed ++;         continue;    }    if (! is_writeable($pathinfo['dirname'])) {        echo $pathinfo['dirname'] . " not writeable.\n";        continue;    }    // create WebP version of image    WebPConvert::convert($file, $pathinfo['dirname'] . '/' . $pathinfo['filename'] . '.webp', $webp_options);    $results = $wpdb->get_results(sprintf("SELECT ID, post_content, post_excerpt FROM {$wpdb->posts} WHERE (`post_content` LIKE '%s' OR `post_excerpt` LIKE '%s') AND `post_type` IN ('post','page','product','product_variation');", "%" . $wpdb->esc_like($pathinfo['basename']) . "%", "%" . $wpdb->esc_like($pathinfo['basename']) . "%"), ARRAY_A);    if (count($results)) {        $sql = sprintf("UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, '%s', '%s'), post_excerpt = REPLACE(post_excerpt, '%s', '%s') WHERE (`post_content` LIKE '%s' OR `post_excerpt` LIKE '%s') AND `post_type` IN ('post','page','product','product_variation');", $pathinfo['basename'], $pathinfo['filename'] . '.webp', $pathinfo['basename'], $pathinfo['filename'] . '.webp', "%" . $wpdb->esc_like($pathinfo['basename']) . "%", "%" . $wpdb->esc_like($pathinfo['basename']) . "%");        $wpdb->query($sql);        printf("Updated image references in %s posts.\n", count($results));    }    $results = $wpdb->get_results(sprintf("SELECT `ID`, `guid`, `post_mime_type` FROM {$wpdb->posts} WHERE `guid` LIKE '%s' AND `post_type` = 'attachment';", "%" . $wpdb->esc_like($pathinfo['basename']) . "%"), ARRAY_A);    if (count($results)) {        $sql = sprintf("UPDATE {$wpdb->posts} SET `guid` = REPLACE(`guid`, '%s', '%s'), `post_mime_type` = 'image/webp' WHERE `guid` LIKE '%s' AND `post_type` = 'attachment';", $pathinfo['basename'], $pathinfo['filename'] . '.webp', '%' . $wpdb->esc_like($pathinfo['basename']) . '%');        $wpdb->query($sql);        printf("Updated guid, post_mime_type in %s attachments.\n", count($results));    }    $wpdb->query(sprintf("UPDATE {$wpdb->postmeta} SET `meta_value` = REPLACE(`meta_value`, '%s', '%s') WHERE `meta_value` LIKE '%s' AND `meta_key` = '_wp_attached_file';", $pathinfo['basename'], $pathinfo['filename'] . '.webp', "%" . $wpdb->esc_like($pathinfo['basename']) . "%"));    $_wp_attachment_metadata = $wpdb->get_row(sprintf("SELECT `post_id`, `meta_value` FROM {$wpdb->postmeta} WHERE `meta_value` LIKE '%s' AND `meta_key` = '_wp_attachment_metadata';", '%' . $wpdb->esc_like($pathinfo['basename']) . '%'), ARRAY_A);    if (! is_null($_wp_attachment_metadata)) {        $old = unserialize($_wp_attachment_metadata['meta_value']);        $new = $old;        $new['file'] = is_string($new['file']) ? str_replace($pathinfo['basename'], $pathinfo['filename'] . '.webp', $new['file']) : $new['file'];        foreach ($new['sizes'] as $sizename => $size) {            $new['sizes'][$sizename]['file'] = is_string($size['file']) ? str_replace($pathinfo['basename'], $pathinfo['filename'] . '.webp', $size['file']) : $size['file'];            if ($old['sizes'][$sizename]['file'] !== $new['sizes'][$sizename]['file']) {                $new['sizes'][$sizename]['mime-type'] = 'image/webp';            }        }        $wpdb->query(sprintf("UPDATE {$wpdb->postmeta} SET `meta_value` = '%s' WHERE `post_id` = %s AND `meta_key` = '_wp_attachment_metadata';", $wpdb->escape(serialize($new)), $_wp_attachment_metadata['post_id']));    }    $converted ++;}printf("Converted %s images and skipped %s that already existed.\n", $converted, $existed);

You can download the file here. The script does modify your database, so to be safe make a backup first. To execute it run

/path/to/porject$ php bin/image-convert.php

Explanation and requirements: the script does a couple of checks first, it expects a project set-up with Composer and WordPress being installed with Composer, too. See my tutorial on how to do that. Additionally, it uses rosell-dk/webp-convert for the actual conversion. PHP needs to be configured with GD library. If it’s missing install it with sudo apt-get install php8.0-gd.

The script uses the highest (lossless) quality settings, multiple passes to get the optimal results, but as a consequence it might be running for quite some time (depending on your servers CPUs, RAM, etc.).