# We built an all-in-one WordPress image toolkit. Here's the architecture behind it

> Source: <https://dev.to/softminal/we-built-an-all-in-one-wordpress-image-toolkit-heres-the-architecture-behind-it-cl>
> Published: 2026-08-13 11:03:01+00:00

We want to talk about why most WordPress image plugins exist as separate, single-purpose tools — and why we think that's the wrong approach.

If you manage WordPress sites, you've probably installed some combination of these: an AI alt text generator, a compression plugin, a WebP converter, a media file renamer, and maybe something to catch broken images. Each one solves a real problem. But together, they create new ones.

They fight over hook priority on `wp_get_attachment_image`

. They store overlapping metadata without knowing about each other. They enqueue separate admin scripts on the same pages. And when one of them breaks on a WordPress update, you're debugging a five-plugin interaction.

ImageCraft is our answer to this. One plugin, one pipeline, every image optimization task in a single codebase. It's free and open source on WordPress.org.

We want to walk through the technical decisions, because we think they're more interesting than a feature list.

Most AI alt text plugins work on a credit model. You buy credits from the developer, they proxy your request to OpenAI or Claude, and pocket the margin. The markup is significant — GPT-4o-mini costs about $0.15 per 100 images at retail, but credit-based plugins charge $3–6 for the same volume.

ImageCraft connects directly to the AI provider from your server. No middleman, no credit packs. You pay the provider at their published rates.

Three providers are supported: Anthropic (Claude), OpenAI, and Google Gemini. Each extends a `BaseAIProvider`

class that handles image fetching, base64 encoding, MIME detection, and prompt construction. Adding a new provider means implementing one method: `generateAltText()`

.

API keys are AES-256-CBC encrypted in a custom database table. The REST API never returns the key — only a boolean `has_key`

field.

A naive implementation would make separate API calls for alt text, title, and caption. Three network round trips, three token charges, three latency waits.

ImageCraft makes one call that returns all three in a structured JSON response:

```
{
  "alt_text": "Tan leather crossbody bag with brass buckle and adjustable strap",
  "title": "Aria Crossbody Bag - Tan Leather",
  "caption": "Handcrafted crossbody bag from the Aria Collection, featuring full-grain tan leather and antique brass hardware."
}
```

The prompt instructs the model to return this exact shape. A `parseMetaJson()`

helper handles the response — including edge cases like markdown-wrapped JSON, extra fields, and truncated responses. Each field has a configurable max length with a `capText()`

function that truncates at the last word boundary rather than mid-word.

For WooCommerce images, the prompt includes product metadata (name, SKU, categories, price) before the image, so the model writes with product context rather than describing the image generically.

Most compression plugins — ShortPixel, Imagify, TinyPNG — upload your images to an external API, compress them there, and download the result. This makes sense if your server doesn't have good image libraries. But most modern hosts have Imagick installed, and it's plenty capable.

ImageCraft compresses directly on the server:

That last step is important. Some already-optimized images (especially PNGs with good compression) get larger after re-encoding. The never-enlarge guard prevents this silently.

Originals are backed up to `wp-content/uploads/icais-originals/`

with the same directory structure. One-click restore copies the backup over the compressed version.

``` php
// Simplified never-enlarge guard
$tempPath = $this->compressToTemp($sourcePath, $quality);
if (filesize($tempPath) >= filesize($sourcePath)) {
    unlink($tempPath);
    return; // original is already optimal
}
```

There are broadly three ways to serve WebP on WordPress:

**Rewrite rules** — `.htaccess`

or nginx config that serves a `.webp`

file when the browser sends `Accept: image/webp`

. Fragile. Breaks on some hosts. Requires server-level config.

**URL replacement** — change the image URL in the HTML from `.jpg`

to `.webp`

. Breaks caching. Confuses CDNs. Hard to reverse.

** <picture> wrapping** — keep the original

`<img>`

intact, wrap it in a `<picture>`

element with `<source>`

entries for WebP/AVIF. The browser picks the best format. The original URL stays as the fallback.We went with approach 3. The converter generates sidecar files (`photo.jpg.webp`

, `photo.jpg.avif`

) next to the originals — for the full-size image and all registered intermediate sizes. A manifest is stored in post meta (`_icais_nextgen`

) with relative paths and byte savings.

On the frontend, two hooks handle delivery:

`wp_content_img_tag`

(WP 6.0+) — wraps content images`post_thumbnail_html`

— wraps featured imagesBoth hooks bail on `is_admin()`

and are wrapped in `try/catch (\Throwable)`

so a conversion bug can never break the frontend rendering. The worst case is: the `<picture>`

wrapper fails silently and the original `<img>`

renders as it always did.

``` php
<!-- Before -->
<img src="photo.jpg" srcset="photo-300x200.jpg 300w, photo-768x512.jpg 768w" ...>

<!-- After (automatic, on the frontend only) -->
<picture>
  <source type="image/webp"
          srcset="photo.jpg.webp 1024w, photo-300x200.jpg.webp 300w, photo-768x512.jpg.webp 768w">
  <img src="photo.jpg" srcset="photo-300x200.jpg 300w, photo-768x512.jpg 768w" ...>
</picture>
```

Cleanup removes all sidecar files and the post meta. The original images and markup are exactly as they were before.

`get_post_metadata`

trick
The broken image fallback has two parts: a frontend JavaScript listener and a PHP metadata filter.

The JS part is straightforward — a capture-phase `error`

listener on `document`

that catches `<img>`

load failures and swaps in the fallback URL. It also does an initial sweep for images with `naturalWidth === 0`

.

The PHP part is more interesting. For missing featured images, we needed `has_post_thumbnail()`

to return `true`

even when the real thumbnail is gone. The function checks `get_post_meta($postId, '_thumbnail_id', true)`

, and if that returns a valid attachment ID that happens to be deleted, `has_post_thumbnail()`

returns `true`

but the image render fails.

For posts where `_thumbnail_id`

is completely empty (the meta doesn't exist), we hook `get_post_metadata`

with a filter:

```
add_filter('get_post_metadata', function ($value, $postId, $metaKey) {
    if ($metaKey !== '_thumbnail_id') return $value;
    // ... bail checks, recursion guard, settings check ...
    return $fallbackImageId;
}, 10, 3);
```

This makes `has_post_thumbnail()`

return `true`

for any post type in the allow-list, and `get_the_post_thumbnail()`

renders the fallback image. The real database is never modified — it's a read-time filter only.

The recursion guard matters because `get_post_meta`

for the fallback image's own `_thumbnail_id`

would trigger the same filter. A static flag prevents the infinite loop.

The free tier allows 50 actions per day across a rolling 24-hour window. Generation and compression share the same pool.

The implementation uses a single `wp_options`

key per user:

``` js
// Stored as: icais_daily_usage_{userId}
[
    'started_at'     => 1691942400,  // window start timestamp
    'ids'            => [41, 42, 43], // attachment IDs (deduped)
    'compress_count' => 7,            // compression count (NOT deduped)
]
```

There's an intentional asymmetry here: generation is deduped per attachment ID (re-generating alt text for the same image within the window is free), while every compression counts. The reasoning is that regenerating alt text is a common review workflow (generate, reject, regenerate with a different tone), but re-compressing the same image is an unusual action that probably indicates a settings change.

`totalCount()`

= `count(ids) + compress_count`

. When it hits 50, the API returns a 429 with a human-readable message about when the window resets.

Pro (detected via the `ICAIS_PRO`

constant defined by the separate Pro add-on plugin) bypasses the check entirely.

A few mistakes from earlier versions, in case they're useful to someone building similar tools:

**Catching \Exception instead of \Throwable.** PHP 7+ throws

`\Error`

for things like missing classes and type mismatches. `catch (\Exception)`

doesn't catch those. Any integration point with an external library or optional dependency now catches `\Throwable`

. This one nearly caused a white-screen on a site that had an older PHP version with a missing Imagick extension.**Settings double-prefix.** Our settings system prepends `icais_`

to every key, and the keys in the defaults array already start with `icais_`

. So everything is stored as `icais_icais_default_tone`

. We caught this too late to fix without breaking existing installations. It works. It's ugly. It's documented. We moved on.

**The wizard gate.** Early versions required an API key to get past the setup wizard. Then we added compression, which doesn't need an API key at all. Users who only wanted compression were blocked by a screen asking for an AI provider key. The wizard is now skippable, and the plugin works fine without any API key configured — you just don't get the AI features.

ImageCraft is free on [WordPress.org](https://wordpress.org/plugins/imagecraft-ai-alt-text-file-renamer-image-seo/). Search "ImageCraft" in your plugin installer, or search for "imagecraft ai alt text" to find it.

Powered By [Softminal](https://www.softminal.com/)
