# How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API

> Source: <https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-2cpc>
> Published: 2026-08-29 12:55:27+00:00

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`

):

``` php
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 `=`

):

``` php
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:

``` php
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.*
