DEV Community

DEUS Automations
DEUS Automations

Posted on

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

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.

The actual problem

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.

Design decision: BYOK, not a hosted service

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'        => '',
    );
}
Enter fullscreen mode Exit fullscreen mode

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.

The actual API call

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
}
Enter fullscreen mode Exit fullscreen mode

Two prompt details earned their place after actually looking at model output on real sites:

  • "Do not start with 'image of' or 'picture of'" — screen readers already announce the element as an image; a generated alt text that starts with "Image of a dog running" is redundant and is explicitly called out as an anti-pattern in WCAG guidance. Without this instruction, GPT-4o-mini defaults to that phrasing constantly.
  • A hard character cap in the prompt, plus a second cap after the fact (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.

Finding the images that actually need it

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Generating one at a time, on purpose

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.

The free-tier limit is deliberate, not a paywall trick

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Takeaways if you're building something similar

  • If you're integrating a vision model for a task with a strict output format (short, no boilerplate prefix, character limit), don't trust the prompt alone — enforce the hard constraints in code after the response comes back.
  • 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.
  • Attachment post_status is not what you'd guess; query inherit, not publish, if you want all media library items regardless of whether they're attached to a published post.
  • For any bulk operation on a shared-hosting WordPress site, prefer many small requests over one big one. max_execution_time will find you eventually.

Missing alt text is exactly the kind of thing that piles up silently on a store's product catalog too. I built ComplianceSnap, a free scanner for Shopify/WooCommerce stores that counts how many product images have no alt text and estimates your ADA/WCAG risk exposure.

Top comments (0)