Extending Symelo Image Optimizations

For add-on developers. Symelo Image Optimizations optimizes images with PHP's image libraries (Imagick and GD). Add-ons can register more engines (encoders for JPEG, PNG, WebP, AVIF, PDF or resizing), add cards and tabs to the plugin screen, and add warnings, notes, system information and status fields. This document is the contract: everything listed here keeps working within API version 1.x.

Contents:

  1. Versioning
  2. Loading an add-on
  3. Hooks
  4. Engines
  5. Background speed
  6. Admin screen
  7. Status, Site Health and system information
  8. Files, options and cleanup
  9. Security rules
  10. Public classes

Versioning

SYMELO_IMAGES_API_VERSION is a major.minor string, currently 1.0. It is defined only when the plugin actually runs (PHP 7.4 or newer), before symelo_images_loaded fires.

  • Minor versions (1.1, 1.2, …) only add: new hooks, new parameters at the end of a hook, new public methods, new methods with a default in AbstractEngine, new Job properties, new tasks in Settings::TASKS, new keys in arrays (status, profiles, system information sections), new CSS classes.
  • Major versions (2.0) may break. Breaking changes are: removing or renaming a hook, a public class, method or constant listed here; changing a hook's or a method's parameters or return type; changing what a Job property, a task name or a profile key means; adding a method to EngineInterface; removing a CSS class or data- attribute listed in Admin screen.
  • EngineInterface doesn't change within 1.x. Optional engine features added later come as methods of AbstractEngine with a default, so engines that extend it keep working. Implementing EngineInterface directly is allowed but gets none of those defaults.
  • Everything not listed in this document (other classes, private and internal methods, the settings option layout, database tables, the symelo_images_* admin-post actions and nonces) is internal and may change in any version.

Check the version before using anything:

function my_addon_api_ok(): bool {
	if ( ! defined( 'SYMELO_IMAGES_API_VERSION' ) ) {
		return false; // Not installed, not active, or PHP too old.
	}
	$version = (string) SYMELO_IMAGES_API_VERSION;
	return 1 === (int) $version && version_compare( $version, '1.0', '>=' ); // Major 1, at least 1.0.
}

Loading an add-on

  • Add Requires Plugins: symelo-image-optimizations to the add-on's header. WordPress 6.5 and newer then won't activate the add-on without the plugin, and won't deactivate the plugin while the add-on is active. Still guard everything: on WordPress 6.4, or when files are removed by hand, the add-on loads without the plugin.
  • Plugins load in alphabetical order of their folder, and network-activated plugins load before the site's, so the add-on may load before or after Symelo Image Optimizations. Attach like this:
if ( did_action( 'symelo_images_loaded' ) ) {
	my_addon_start( \Symelo\Images\Plugin::instance() );
} else {
	add_action( 'symelo_images_loaded', 'my_addon_start' );
}
// Still not started once every plugin is loaded: show an admin notice.
add_action( 'plugins_loaded', 'my_addon_check', 20 );
  • In my_addon_start(), check the API version first. Load nothing that extends or implements a class of the plugin (engines, for example) before that: without the plugin, PHP would stop with a fatal error. The add-on's main file, its autoloader and its bootstrap code must work without the plugin.
  • Deactivating Symelo Image Optimizations must never break the site: the add-on then only shows a notice ("Install and activate Symelo Image Optimizations").

Hooks

Actions

ActionArgumentsWhen it fires
symelo_images_loadedPlugin $pluginOnce, at the end of the plugin's boot, while plugins are loading. Attach your add-on here.
symelo_images_register_enginesEngineManager $enginesEach time the engine list is built: on first use in a request (lazily, e.g. when an image is optimized, on the plugin screen, REST GET /status, Site Health), and again after Plugin::reset_engines() (after settings are saved). Call $engines->add( $engine ). Don't keep the manager.
symelo_images_file_optimizedFileRecord $after, FileRecord $beforeAfter a file was optimized, failed, was skipped or was restored. Read-only: don't change files here.
symelo_images_engines_tabPlugin $pluginAt the end of the Engines tab, after its own cards. Print your cards here.
symelo_images_tab_{$slug}Plugin $pluginPrints the panel of a tab added with symelo_images_tabs. Every tab is printed on each load of the screen and shown when clicked.

Filters

FilterArgumentsReturnWhat for
symelo_images_capabilitystring $capability ('manage_options')CapabilityWho may use the plugin screen, bulk optimization and the REST API. Read it with Plugin::capability().
symelo_images_engine_orderstring[] $ids, string $taskEngine IDsThe automatic order of a task. $ids is already sorted by rank; reorder or remove IDs. Unknown IDs and engines that don't handle the task are ignored.
symelo_images_tabsarray $tabs ([])slug => labelExtra tabs, shown before Guide and Support. Slugs go through sanitize_key(); built-in slugs (overview, compression, delivery, folders, tools, advanced, guide, support) are ignored.
symelo_images_warningsstring[] $warnings, Plugin $pluginPlain-text stringsWarnings shown on the Overview tab (one notice each), in the system information, wp symelo-images status and GET /status.
symelo_images_notesstring[] $notes, Plugin $pluginPlain-text stringsNotes shown under the engine table on the Engines tab, in the system information and GET /status.
symelo_images_system_infoarray $sections, Plugin $plugintitle => [ label => value ]Sections of the Support tab's system information (plain text). Add a section of your own. Never include passwords, keys or tokens.
symelo_images_rest_statusarray $status, Plugin $pluginArrayThe GET /wp-json/symelo-images/v1/status response. Add one key of your own (e.g. my_addon); don't change existing keys.
symelo_images_pacearray $profile, string $speedProfileThe Background speed profiles (see Background speed).
symelo_images_max_pixelsint $pixels (60,000,000)PixelsLarger images are skipped.
symelo_images_max_bytesint $bytes (100 MB)BytesLarger files are skipped.
symelo_images_imagick_limitsarray $limitsLimitsImageMagick resource limits (RESOURCETYPE_* constant name => value).
symelo_images_worker_budgetint $secondsSecondsHow long a background run works (minimum 5).
symelo_images_dispatch_argsarray $argswp_remote_post() argumentsThe request the site sends to itself to start background runs.
symelo_images_skip_pagebool $skip (false)Booltrue skips the <picture> and lazy-loading pass for this request.
symelo_images_more_from_symeloarray $products ([])Products"More from Symelo LTD" on the Support tab.

Site Health tests: use core's site_status_tests filter with test IDs of your own prefix; nothing from this plugin is needed.

Engines

An engine encodes files for one or more tasks: jpeg (JPEG compression), png (PNG compression), webp (WebP copies), avif (AVIF copies), pdf (PDF compression) and resize (scaling and rotating, also used to prepare copies for engines that can't). Settings::TASKS lists them. The plugin brings imagick and gd; no built-in engine handles pdf.

Registering

use Symelo\Images\Engines\AbstractEngine;
use Symelo\Images\Engines\EngineException;
use Symelo\Images\Engines\EngineManager;
use Symelo\Images\Engines\Job;

final class My_Webp_Engine extends AbstractEngine {

	public function id(): string {
		return 'my-webp';
	}

	public function label(): string {
		return 'My WebP encoder';
	}

	public function tasks(): array {
		return array( 'webp' => $this->works() ? true : __( 'The encoder is missing.', 'my-addon' ) );
	}

	public function rank( string $task ): int {
		return 10; // Before Imagick (50) and GD (60).
	}

	public function accepts( Job $job ): bool {
		return $this->can( $job->task ) && in_array( $job->info->format, array( 'jpeg', 'png' ), true ) && ! $job->needs_preparing();
	}

	public function run( Job $job, string $dest ): void {
		if ( ! my_encode( $job->source(), $dest, $job->quality ) ) {
			throw new EngineException( esc_html__( 'My encoder failed.', 'my-addon' ) );
		}
	}

	private function works(): bool {
		return true;
	}
}

add_action(
	'symelo_images_register_engines',
	static function ( EngineManager $engines ) {
		$engines->add( new My_Webp_Engine() );
	}
);

EngineManager::add() replaces an engine with the same ID; remove( $id ) removes one. IDs must be what sanitize_key() returns (lowercase letters, digits, _, -) and not auto. Make them distinctive: prefix them unless they are the name of a well-known program (cwebp).

EngineInterface

MethodContract
id(): stringUnique, stable ID. It is stored as the admin's choice for a task, so never change it.
label(): stringName shown to admins.
origin(): stringWhere the engine comes from, shown under its name (e.g. "PHP extension", "Installed on the server"), or ''.
version(): stringDetected version, or ''. With '', an engine that can't do anything shows Not found; with a version, Outdated.
note(): stringOne plain-text message about the engine (why it isn't available, or a warning), or ''.
tasks(): arrayEvery task the engine handles, with whether it works on this server: task => true, or task => 'reason' (plain text). Leave out tasks it never handles. Called often: cache expensive checks (per request, or in an option with a fingerprint).
rank( string $task ): intPosition in the automatic order: lower first. Imagick is 50 and GD 60 for every task; AbstractEngine::RANK is 100. Dedicated encoders that beat the PHP libraries return less than 50.
explicit_only(): booltrue: never chosen by Automatic, only when the admin picks it for a task (e.g. a lossy engine with security trade-offs).
can_transform(): boolWhether the engine itself applies EXIF orientation and scaling (Job::$resize). See Prepared copies.
accepts( Job $job ): boolWhether it takes this particular file (format, animation, colour space, size, memory, PDF features…). Declining is normal and cheap: the next engine gets the job. Must not write anything.
run( Job $job, string $dest ): voidEncode into $dest. Throw EngineException on failure (see Errors and results).

AbstractEngine implements everything except id(), label(), tasks() and run(): no origin, version or note, rank static::RANK (100), not explicit-only, can't transform, accepts() = can( $job->task ). It adds can( string $task ): bool (the task is true in tasks()). EngineManager::can( $engine, $task ) does the same for any engine.

Ranks and the automatic order

For a task, the order is built from every registered engine whose tasks() has that task (usable here or not):

  1. sorted by rank( $task ), lower first; equal ranks keep the order of registration (Imagick, GD, then add-ons in the order they called add());
  2. passed through symelo_images_engine_order ($ids, $task), which may reorder or remove IDs.

That order is what the Engines tab lists. To optimize a file, the plugin tries, in turn:

  1. the engine the admin chose for the task (Engine for each job), if it is still registered and works here;
  2. every other engine in the order, except explicit-only ones;

and uses the first one whose tasks() has the task as true and whose accepts() returns true. If run() fails, the next one is tried. The engine shown as Automatic (…) is the first one that works for the task in general (EngineManager::preferred()).

The choice per task is validated against the registered engines: saving an engine that is registered but can't do the task here gives "engine can't do that job on this server, so Automatic is used instead." A stored choice for an engine that is no longer registered (the add-on was deactivated) silently counts as Automatic (EngineManager::choice() returns auto) and is dropped the next time the Engines tab is saved.

Job

Job describes one piece of work. Engines read its properties and never change them (the plugin hands the same job to the next engine).

Property / methodMeaning
string $taskThe task (Settings::TASKS).
ImageInfo $infoThe source: path, format (jpeg, png, gif, webp, avif, pdf, sniffed from the content), bytes, width, height, animated, alpha, cmyk, bit_depth, orientation (EXIF, 1–8), has_icc, srgb_icc, jpeg_quality (estimated, 0 when unknown).
source(): stringThe source file's absolute path ($info->path). Read it; never write to it.
int $quality1–100, or 0 for lossless. For jpeg at 0 only lossless optimization is acceptable.
?array $resize[width, height] to scale to, or null.
bool $stripRemove EXIF/XMP/IPTC. The plugin also strips metadata from JPEG, PNG and WebP results itself, keeping the colour profile and orientation, so keeping markers is safe.
bool $lossy_pngpng task: colour reduction allowed.
string $pdf_presetpdf task: '' lossless (Lossless level), printer moderate (Balanced, Custom), ebook smaller (Aggressive). Lossless engines ignore it.
int $threadsEncoder threads from the Background speed (0 = encoder default).
needs_preparing(): boolThe pixels must be scaled ($resize) or rotated (a JPEG with orientation > 1).
with_source( ImageInfo $info ): JobA copy reading another file, with resize cleared.
libavif_quality(): int$quality mapped to libavif's scale (for libavif-based AVIF encoders).

Prepared copies

Engines that can't rotate or scale return false from can_transform() and decline (accepts() → false) jobs that needs_preparing(). The plugin then makes a lossless PNG that is already rotated and scaled (with the engine for resize, Imagick or GD) and calls accepts() again with $job->with_source( $prepared ). An engine that doesn't decode pixels (a lossless JPEG optimizer that keeps the EXIF orientation, for example) may accept rotated JPEGs as long as $resize is null.

Errors and results

  • run() writes the result to $dest, a new path (overwrite it if it exists). During optimization it is a fresh file in the plugin's private temporary folder, which the plugin deletes afterwards; the delivery self-test writes test copies in uploads/symelo-images/public/. Don't write anywhere else except temporary files of your own in Paths::temp_dir(), and delete those before returning, also on failure.
  • On failure throw EngineException with a message for admins, escaped like every exception message in WordPress: new EngineException( esc_html__( '…', 'my-addon' ) ) or esc_html( sprintf( … ) ). The plugin stores $e->plain_message(), which undoes that escaping, and escapes again wherever it prints it. The message is shown with the file, and the next engine is tried.
  • When the engine worked but the result can't be smaller, throw EngineException::no_gain(): the plugin stops trying other engines for that job and keeps what it has.
  • After run(), the plugin checks the output: its content must be the expected format (sniffed from the bytes), same-format results are kept only when meaningfully smaller (1 KB or 1%, unless resized), WebP and AVIF copies only when smaller than the file (an AVIF also smaller than the WebP), and PDFs only when the output is a PDF and smaller. Writing in place is atomic, and the original is backed up first.
  • PDF engines check Image\PdfInfo::read( $job->source() ) (encrypted, signed, forms): signed PDFs must never be rewritten.

Background speed

Queue\Pace holds the speed profiles of Advanced → Background speed (fast, normal, gentle).

KeyMeaningfastnormalgentle
budgetSeconds a background slice works25155
pauseSeconds between bulk slices0030
niceCPU priority for encoder programs (0 normal … 19 lowest)01019
idle_ioDisk priority "idle" for encoder programs (Linux ionice -c 3)nonoyes
threadsEncoder threads (0 = encoder default); also on Job::$threads021
max_loadBulk work waits while the 1-minute load per CPU core is at least this (0 = never)02.00.7
  • Pace::current(): array is the profile in effect (with the symelo_images_pace filter applied). An engine that starts encoder programs reads nice and idle_io from it for every run, and its thread count from Job::$threads.
  • Pace::profile( string $speed ): array returns one profile; Pace::SPEEDS lists the speeds.
  • Pace::busy( array $profile ): bool, Pace::load_per_core(): ?float and Pace::cpu_count(): int tell whether the server is busy, for background work of your own.
  • Uploads and explicit runs ("Optimize now", WP-CLI without --paced, the REST API) don't wait for pauses or load, but still use the profile's priority and threads.

Admin screen

The screen is Media → Image Optimizations (page slug Admin\Admin::PAGE, symelo-images; screen ID media_page_symelo-images). Every tab is printed on each page load and switches in the browser, so cards must be cheap to render.

  • Engines tab cards: print them on symelo_images_engines_tab.
  • Tabs: add slug => label with symelo_images_tabs, then print the panel's content on symelo_images_tab_{$slug}. The plugin wraps it in <div class="symelo-panel" data-panel="{slug}">. Admin\Ui::url( array( 'tab' => $slug ) ) links to it; links with data-tab-link="{slug}" switch tabs in place.
  • Assets: the plugin's assets/admin.css and assets/admin.js are loaded on the screen. Load your own only on that screen (admin_enqueue_scripts, $hook_suffix contains symelo-images), with your own handle.

Markup and helpers to reuse (stable within 1.x):

Class, attribute or helperUse
<div class="symelo-card"> with an <h2>A card.
<table class="widefat striped symelo-table">A table inside a card.
Ui::badge( $status, $label ) → .symelo-badge .symelo-badge--{ok,warn,bad,info,muted}Status badge (escapes its label; print it with a phpcs:ignore for OutputNotEscaped and that reason).
Ui::checkbox( $name, $checked, $label, $help, $disabled, $why ) → .symelo-checkA checkbox that posts 0 when unticked; disabled ones show $why in .symelo-unsupported.
.description, .symelo-note, .symelo-unsupported, .symelo-inline-errorHelp text, a note under a status, why something isn't possible, an inline error.
.symelo-button-rowButtons in a row (on a <form> or <p>).
.button-link.symelo-link-danger, .button.symelo-button-dangerDestructive actions.
data-confirm="Question?" on a submit buttonAsks before submitting (an empty value asks "Are you sure?").
.symelo-options / label.symelo-optionRadio options with a title and a description.
.symelo-inline-fields, table.symelo-meta, details.symelo-details, textarea.symelo-code with a data-copy="#id" buttonInline fields, a label/value table, a collapsible section, copyable code.
Ui::url( array $args ), Ui::bytes( int ), Ui::time( int )Screen URL, file sizes, dates in the site's time zone.
Admin\Notices::flash( $type, $message )A plain-text message (success, error, warning, info) shown under the header after your handler redirects back.

Forms and handlers:

  • Post to admin-post.php with an action of your own prefix (admin_post_{your_prefix}_{action}). Never post to the plugin's symelo_images_* actions or use its nonces.
  • In the handler: check current_user_can( \Symelo\Images\Plugin::capability() ) first (stop with wp_die( …, 403 )), then check_admin_referer( '{your_prefix}_{action}' ), then sanitize every field. Redirect back with wp_safe_redirect( Ui::url( array( 'tab' => 'tools' ) ) ); exit;.
  • After changing anything that affects engines, call Plugin::instance()->reset_engines() so the next use rebuilds the engine list.
  • REST routes: a namespace of your own, permission_callback checking Plugin::capability(), argument schemas for every input.

Status, Site Health and system information

  • symelo_images_warnings / symelo_images_notes: plain-text strings (see Filters). Warnings are for things the admin should fix (an encoder with known security holes); notes are calm information. The plugin's own Site Health test "Image libraries are up to date" only covers its libraries; add your own Site Health tests with core's site_status_tests.
  • symelo_images_system_info: $sections['My Add-on'] = array( 'Version' => '1.2.3', … ). Values are plain text.
  • symelo_images_rest_status: $status['my_addon'] = array( … ).

Files, options and cleanup

  • The plugin owns uploads/symelo-images/ (backups/, tmp/, public/) and deletes uploads/symelo-images/tools/ (a folder of pre-release versions) when it is installed or updated. Keep add-on files in a folder of your own, for example uploads/{your-slug}/, protected like the plugin's (index.php, .htaccess with Require all denied, web.config).
  • Support\Paths::temp_dir() is the plugin's private temporary folder, for short-lived files of engines. Files older than a day are deleted daily; delete yours right away.
  • Keep settings in an option of your own. Never write symelo_images_settings, the plugin's table or its other options.
  • When the plugin is deleted, it removes transients whose names start with symelo_images_, so add-on transients named like that are caches only.
  • Clean up on your own uninstall (uninstall.php), without loading the plugin.

Security rules

Add-ons should keep the plugin's rules:

  • PHP 7.4 syntax and WordPress 6.4 APIs, unless the add-on requires more.
  • Capability, then nonce, in every handler; sanitize input, escape output, $wpdb->prepare() every query.
  • Never accept a file path or program path from a request or a setting; work only on Job::source(), $dest and folders you own.
  • Run programs only with argument arrays (no shell), a minimal environment and time limits, and refuse versions with known security holes.
  • Messages in EngineException are escaped where thrown.

Public classes

Stable within API 1.x (namespace Symelo\Images):

ClassPublic API
Plugininstance(), capability(), settings(), engines(), reset_engines()
Engines\EngineInterface, Engines\AbstractEngineAs above
Engines\EngineManageradd(), remove(), all(), get(), order(), candidates(), choice(), preferred(), supports(), can() (static), usable_tasks() (static)
Engines\JobProperties and methods above
Engines\EngineExceptionNO_GAIN, no_gain(), plain_message()
Image\ImageInfoPublic properties, read(), sniff(), read_bytes(), pixels(), is_image()
Image\PdfInforead()
Queue\PaceSPEEDS, profile(), current(), busy(), load_per_core(), cpu_count()
Settings\SettingsTASKS (setting keys themselves aren't part of the API)
Support\Pathstemp_dir()
Support\SystemPackagesfind(), is_patched() (Debian/Ubuntu package versions with backported security fixes)
Admin\AdminPAGE
Admin\Uiurl(), badge(), checkbox(), bytes(), time()
Admin\Noticesflash()

extending.md