If you've ever tried to fix accessibility on an old WordPress site, you know the drill: hundreds of images in the Media Library, most with empty alt
attributes, and a WCAG 2.1 audit (or a client demanding one) breathing down your neck. Writing alt text by hand for 400 images is not a fun Tuesday. Every "AI alt text" SaaS I looked at wanted a monthly subscription, routed my images through their own servers, and gave me zero control over which model actually looked at the picture.
This post is about the plugin I built to fix that for my own sites, and the handful of implementation details that turned out to matter more than expected.
WCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every attachment
post of MIME type image
should have _wp_attachment_image_alt
set to something meaningful, not "IMG_4821.jpg" and not empty.
Doing this with a vision-capable LLM is trivial in principle — send the image, ask for a short description, save it as the alt attribute. The part that's not trivial, if you don't want another recurring SaaS bill and don't want to hand a third party your whole media library, is: whose API key, which model, and where does the image actually go.
The plugin (Alt Text BYOK
) doesn't call any server of mine. It calls whatever OpenAI-compatible chat/completions
endpoint you configure, with your own API key. That's the entire trust model: your images go from your WordPress install directly to the provider you already chose (OpenAI, or any of the growing list of OpenAI-compatible vision endpoints), and nowhere else.
The settings are deliberately just four fields:
function atbyok_default_settings() {
return array(
'api_base' => 'https://api.openai.com/v1',
'api_key' => '',
'model' => 'gpt-4o-mini',
'language' => 'English',
'overwrite_existing' => '0',
'license_key' => '',
);
}
api_base
is the detail that matters most for portability: it's not hardcoded to OpenAI. Point it at any provider that speaks the same chat/completions
shape with image content parts, and it works. That includes several free-tier vision models if you want to run the whole thing at zero cost.
Nothing exotic — a single chat/completions
request with a multimodal content
array (text instruction + image_url
):
function atbyok_call_vision_api( $image_url, $settings ) {
$endpoint = trailingslashit( $settings['api_base'] ) . 'chat/completions';
$prompt = sprintf(
'Describe this image in %s as a concise, descriptive ALT text for web accessibility. ' .
'Maximum 125 characters. Do not start with "image of" or "picture of". ' .
'Reply with the ALT text only, no quotes, no extra commentary.',
$settings['language']
);
$body = array(
'model' => $settings['model'],
'messages' => array(
array(
'role' => 'user',
'content' => array(
array( 'type' => 'text', 'text' => $prompt ),
array(
'type' => 'image_url',
'image_url' => array( 'url' => $image_url ),
),
),
),
),
'max_tokens' => 60,
);
$response = wp_remote_post( $endpoint, array(
'timeout' => 30,
'headers' => array(
'Authorization' => 'Bearer ' . $settings['api_key'],
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( $body ),
) );
// ... error handling, then trim/sanitize the returned text
}
Two prompt details earned their place after actually looking at model output on real sites:
mb_substr( $alt, 0, 160 )
) — models don't reliably respect "maximum 125 characters" as an instruction, so the code truncates again server-side rather than trusting the model's arithmetic.The query that drives the bulk-fix screen is a plain WP_Query
against attachments missing the meta key entirely — not empty string, missing, which is a different meta_query compare
(NOT EXISTS
vs =
):
function atbyok_attachments_missing_alt( $limit = 50 ) {
$q = new WP_Query( array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => 'image',
'posts_per_page' => $limit,
'meta_query' => array(
array(
'key' => '_wp_attachment_image_alt',
'compare' => 'NOT EXISTS',
),
),
) );
return $q->posts;
}
post_status => inherit
is the detail that's easy to get wrong: attachments don't use the normal publish
/draft
statuses, they inherit their parent post's status (or inherit
on their own when unattached). Query for publish
here instead and you silently miss every unattached media-library image — which, in practice on most sites, is most of them.
The admin UI processes images one AJAX call per image instead of batching them server-side into one big request. That's not an accident — a bulk endpoint that loops over 300 images inside a single PHP request runs straight into max_execution_time
on any shared host, and a failure partway through gives you no idea which images actually got done. One request per image, driven by JS, means progress is visible and a timeout on image #214 doesn't lose the 213 that already succeeded.
WordPress.org doesn't allow selling anything inside a plugin listed there, so the free version ships with a genuine cap — 30 generations/month, tracked with a month-keyed option that resets itself:
function atbyok_usage_get() {
$data = get_option( 'atbyok_usage', array() );
$ym = gmdate( 'Y-m' );
if ( ! is_array( $data ) || ( $data['ym'] ?? '' ) !== $ym ) {
return array( 'ym' => $ym, 'count' => 0 );
}
return $data;
}
Using the current year-month as the array key instead of a scheduled cron job to reset the counter means there's no maintenance task that can silently stop firing — the counter just naturally starts over the first time atbyok_usage_get()
runs in a new month.
meta_query
on attachments: always double check NOT EXISTS
vs = ''
vs plain absence — WordPress doesn't guarantee the meta row exists at all for older uploads.post_status
is not what you'd guess; query inherit
, not publish
, if you want max_execution_time
will find you eventually.I'm the author of Alt Text BYOK, a WordPress plugin that does exactly what's described above — bring your own API key, images never touch a third-party server of mine, free tier included.