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:
- Versioning
- Loading an add-on
- Hooks
- Engines
- Background speed
- Admin screen
- Status, Site Health and system information
- Files, options and cleanup
- Security rules
- 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, newJobproperties, new tasks inSettings::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
Jobproperty, a task name or a profile key means; adding a method toEngineInterface; removing a CSS class ordata-attribute listed in Admin screen. EngineInterfacedoesn't change within 1.x. Optional engine features added later come as methods ofAbstractEnginewith a default, so engines that extend it keep working. ImplementingEngineInterfacedirectly 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-optimizationsto 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
| Action | Arguments | When it fires |
|---|---|---|
symelo_images_loaded | Plugin $plugin | Once, at the end of the plugin's boot, while plugins are loading. Attach your add-on here. |
symelo_images_register_engines | EngineManager $engines | Each 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_optimized | FileRecord $after, FileRecord $before | After a file was optimized, failed, was skipped or was restored. Read-only: don't change files here. |
symelo_images_engines_tab | Plugin $plugin | At the end of the Engines tab, after its own cards. Print your cards here. |
symelo_images_tab_{$slug} | Plugin $plugin | Prints 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
| Filter | Arguments | Return | What for |
|---|---|---|---|
symelo_images_capability | string $capability ('manage_options') | Capability | Who may use the plugin screen, bulk optimization and the REST API. Read it with Plugin::capability(). |
symelo_images_engine_order | string[] $ids, string $task | Engine IDs | The 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_tabs | array $tabs ([]) | slug => label | Extra 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_warnings | string[] $warnings, Plugin $plugin | Plain-text strings | Warnings shown on the Overview tab (one notice each), in the system information, wp symelo-images status and GET /status. |
symelo_images_notes | string[] $notes, Plugin $plugin | Plain-text strings | Notes shown under the engine table on the Engines tab, in the system information and GET /status. |
symelo_images_system_info | array $sections, Plugin $plugin | title => [ 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_status | array $status, Plugin $plugin | Array | The 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_pace | array $profile, string $speed | Profile | The Background speed profiles (see Background speed). |
symelo_images_max_pixels | int $pixels (60,000,000) | Pixels | Larger images are skipped. |
symelo_images_max_bytes | int $bytes (100 MB) | Bytes | Larger files are skipped. |
symelo_images_imagick_limits | array $limits | Limits | ImageMagick resource limits (RESOURCETYPE_* constant name => value). |
symelo_images_worker_budget | int $seconds | Seconds | How long a background run works (minimum 5). |
symelo_images_dispatch_args | array $args | wp_remote_post() arguments | The request the site sends to itself to start background runs. |
symelo_images_skip_page | bool $skip (false) | Bool | true skips the <picture> and lazy-loading pass for this request. |
symelo_images_more_from_symelo | array $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
| Method | Contract |
|---|---|
id(): string | Unique, stable ID. It is stored as the admin's choice for a task, so never change it. |
label(): string | Name shown to admins. |
origin(): string | Where the engine comes from, shown under its name (e.g. "PHP extension", "Installed on the server"), or ''. |
version(): string | Detected version, or ''. With '', an engine that can't do anything shows Not found; with a version, Outdated. |
note(): string | One plain-text message about the engine (why it isn't available, or a warning), or ''. |
tasks(): array | Every 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 ): int | Position 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(): bool | true: never chosen by Automatic, only when the admin picks it for a task (e.g. a lossy engine with security trade-offs). |
can_transform(): bool | Whether the engine itself applies EXIF orientation and scaling (Job::$resize). See Prepared copies. |
accepts( Job $job ): bool | Whether 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 ): void | Encode 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):
- sorted by
rank( $task ), lower first; equal ranks keep the order of registration (Imagick, GD, then add-ons in the order they calledadd()); - 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:
- the engine the admin chose for the task (Engine for each job), if it is still registered and works here;
- 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 / method | Meaning |
|---|---|
string $task | The task (Settings::TASKS). |
ImageInfo $info | The 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(): string | The source file's absolute path ($info->path). Read it; never write to it. |
int $quality | 1–100, or 0 for lossless. For jpeg at 0 only lossless optimization is acceptable. |
?array $resize | [width, height] to scale to, or null. |
bool $strip | Remove 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_png | png task: colour reduction allowed. |
string $pdf_preset | pdf task: '' lossless (Lossless level), printer moderate (Balanced, Custom), ebook smaller (Aggressive). Lossless engines ignore it. |
int $threads | Encoder threads from the Background speed (0 = encoder default). |
needs_preparing(): bool | The pixels must be scaled ($resize) or rotated (a JPEG with orientation > 1). |
with_source( ImageInfo $info ): Job | A 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 inuploads/symelo-images/public/. Don't write anywhere else except temporary files of your own inPaths::temp_dir(), and delete those before returning, also on failure.- On failure throw
EngineExceptionwith a message for admins, escaped like every exception message in WordPress:new EngineException( esc_html__( '…', 'my-addon' ) )oresc_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).
| Key | Meaning | fast | normal | gentle |
|---|---|---|---|---|
budget | Seconds a background slice works | 25 | 15 | 5 |
pause | Seconds between bulk slices | 0 | 0 | 30 |
nice | CPU priority for encoder programs (0 normal … 19 lowest) | 0 | 10 | 19 |
idle_io | Disk priority "idle" for encoder programs (Linux ionice -c 3) | no | no | yes |
threads | Encoder threads (0 = encoder default); also on Job::$threads | 0 | 2 | 1 |
max_load | Bulk work waits while the 1-minute load per CPU core is at least this (0 = never) | 0 | 2.0 | 0.7 |
Pace::current(): arrayis the profile in effect (with thesymelo_images_pacefilter applied). An engine that starts encoder programs readsniceandidle_iofrom it for every run, and its thread count fromJob::$threads.Pace::profile( string $speed ): arrayreturns one profile;Pace::SPEEDSlists the speeds.Pace::busy( array $profile ): bool,Pace::load_per_core(): ?floatandPace::cpu_count(): inttell 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 => labelwithsymelo_images_tabs, then print the panel's content onsymelo_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 withdata-tab-link="{slug}"switch tabs in place. - Assets: the plugin's
assets/admin.cssandassets/admin.jsare loaded on the screen. Load your own only on that screen (admin_enqueue_scripts,$hook_suffixcontainssymelo-images), with your own handle.
Markup and helpers to reuse (stable within 1.x):
| Class, attribute or helper | Use |
|---|---|
<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-check | A checkbox that posts 0 when unticked; disabled ones show $why in .symelo-unsupported. |
.description, .symelo-note, .symelo-unsupported, .symelo-inline-error | Help text, a note under a status, why something isn't possible, an inline error. |
.symelo-button-row | Buttons in a row (on a <form> or <p>). |
.button-link.symelo-link-danger, .button.symelo-button-danger | Destructive actions. |
data-confirm="Question?" on a submit button | Asks before submitting (an empty value asks "Are you sure?"). |
.symelo-options / label.symelo-option | Radio options with a title and a description. |
.symelo-inline-fields, table.symelo-meta, details.symelo-details, textarea.symelo-code with a data-copy="#id" button | Inline 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.phpwith an action of your own prefix (admin_post_{your_prefix}_{action}). Never post to the plugin'ssymelo_images_*actions or use its nonces. - In the handler: check
current_user_can( \Symelo\Images\Plugin::capability() )first (stop withwp_die( …, 403 )), thencheck_admin_referer( '{your_prefix}_{action}' ), then sanitize every field. Redirect back withwp_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_callbackcheckingPlugin::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'ssite_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 deletesuploads/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 exampleuploads/{your-slug}/, protected like the plugin's (index.php,.htaccesswithRequire 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(),$destand 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
EngineExceptionare escaped where thrown.
Public classes
Stable within API 1.x (namespace Symelo\Images):
| Class | Public API |
|---|---|
Plugin | instance(), capability(), settings(), engines(), reset_engines() |
Engines\EngineInterface, Engines\AbstractEngine | As above |
Engines\EngineManager | add(), remove(), all(), get(), order(), candidates(), choice(), preferred(), supports(), can() (static), usable_tasks() (static) |
Engines\Job | Properties and methods above |
Engines\EngineException | NO_GAIN, no_gain(), plain_message() |
Image\ImageInfo | Public properties, read(), sniff(), read_bytes(), pixels(), is_image() |
Image\PdfInfo | read() |
Queue\Pace | SPEEDS, profile(), current(), busy(), load_per_core(), cpu_count() |
Settings\Settings | TASKS (setting keys themselves aren't part of the API) |
Support\Paths | temp_dir() |
Support\SystemPackages | find(), is_patched() (Debian/Ubuntu package versions with backported security fixes) |
Admin\Admin | PAGE |
Admin\Ui | url(), badge(), checkbox(), bytes(), time() |
Admin\Notices | flash() |