{"slug": "how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-to", "title": "How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API", "summary": "A developer built a WordPress plugin, Alt Text BYOK, that generates WCAG-compliant alt text for images using a vision-capable LLM while keeping images private by allowing users to bring their own API key and endpoint. The plugin supports any OpenAI-compatible chat/completions endpoint, ensuring images are sent directly to the user's chosen provider, and includes prompt engineering details to produce concise, descriptive alt text.", "body_md": "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`\n\nattributes, 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.\n\nThis 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.\n\nWCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every `attachment`\n\npost of MIME type `image`\n\nshould have `_wp_attachment_image_alt`\n\nset to something meaningful, not \"IMG_4821.jpg\" and not empty.\n\nDoing 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.\n\nThe plugin (`Alt Text BYOK`\n\n) doesn't call any server of mine. It calls whatever OpenAI-compatible `chat/completions`\n\nendpoint 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.\n\nThe settings are deliberately just four fields:\n\n```\nfunction atbyok_default_settings() {\n    return array(\n        'api_base'           => 'https://api.openai.com/v1',\n        'api_key'            => '',\n        'model'              => 'gpt-4o-mini',\n        'language'           => 'English',\n        'overwrite_existing' => '0',\n        'license_key'        => '',\n    );\n}\n```\n\n`api_base`\n\nis the detail that matters most for portability: it's not hardcoded to OpenAI. Point it at any provider that speaks the same `chat/completions`\n\nshape 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.\n\nNothing exotic — a single `chat/completions`\n\nrequest with a multimodal `content`\n\narray (text instruction + `image_url`\n\n):\n\n``` php\nfunction atbyok_call_vision_api( $image_url, $settings ) {\n    $endpoint = trailingslashit( $settings['api_base'] ) . 'chat/completions';\n    $prompt   = sprintf(\n        'Describe this image in %s as a concise, descriptive ALT text for web accessibility. ' .\n        'Maximum 125 characters. Do not start with \"image of\" or \"picture of\". ' .\n        'Reply with the ALT text only, no quotes, no extra commentary.',\n        $settings['language']\n    );\n\n    $body = array(\n        'model'      => $settings['model'],\n        'messages'   => array(\n            array(\n                'role'    => 'user',\n                'content' => array(\n                    array( 'type' => 'text', 'text' => $prompt ),\n                    array(\n                        'type'      => 'image_url',\n                        'image_url' => array( 'url' => $image_url ),\n                    ),\n                ),\n            ),\n        ),\n        'max_tokens' => 60,\n    );\n\n    $response = wp_remote_post( $endpoint, array(\n        'timeout' => 30,\n        'headers' => array(\n            'Authorization' => 'Bearer ' . $settings['api_key'],\n            'Content-Type'  => 'application/json',\n        ),\n        'body'    => wp_json_encode( $body ),\n    ) );\n    // ... error handling, then trim/sanitize the returned text\n}\n```\n\nTwo prompt details earned their place after actually looking at model output on real sites:\n\n`mb_substr( $alt, 0, 160 )`\n\n) — 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`\n\nagainst attachments missing the meta key entirely — not empty string, *missing*, which is a different `meta_query compare`\n\n(`NOT EXISTS`\n\nvs `=`\n\n):\n\n``` php\nfunction atbyok_attachments_missing_alt( $limit = 50 ) {\n    $q = new WP_Query( array(\n        'post_type'      => 'attachment',\n        'post_status'    => 'inherit',\n        'post_mime_type' => 'image',\n        'posts_per_page' => $limit,\n        'meta_query'     => array(\n            array(\n                'key'     => '_wp_attachment_image_alt',\n                'compare' => 'NOT EXISTS',\n            ),\n        ),\n    ) );\n    return $q->posts;\n}\n```\n\n`post_status => inherit`\n\nis the detail that's easy to get wrong: attachments don't use the normal `publish`\n\n/`draft`\n\nstatuses, they inherit their parent post's status (or `inherit`\n\non their own when unattached). Query for `publish`\n\nhere instead and you silently miss every unattached media-library image — which, in practice on most sites, is most of them.\n\nThe 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`\n\non 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.\n\nWordPress.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:\n\n``` php\nfunction atbyok_usage_get() {\n    $data = get_option( 'atbyok_usage', array() );\n    $ym   = gmdate( 'Y-m' );\n    if ( ! is_array( $data ) || ( $data['ym'] ?? '' ) !== $ym ) {\n        return array( 'ym' => $ym, 'count' => 0 );\n    }\n    return $data;\n}\n```\n\nUsing 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()`\n\nruns in a new month.\n\n`meta_query`\n\non attachments: always double check `NOT EXISTS`\n\nvs `= ''`\n\nvs plain absence — WordPress doesn't guarantee the meta row exists at all for older uploads.`post_status`\n\nis not what you'd guess; query `inherit`\n\n, not `publish`\n\n, if you want `max_execution_time`\n\nwill 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.*", "url": "https://wpnews.pro/news/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-to", "canonical_source": "https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-2cpc", "published_at": "2026-08-29 12:55:27+00:00", "updated_at": "2026-08-29 13:19:06.807969+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "generative-ai"], "entities": ["WordPress", "OpenAI", "Alt Text BYOK", "WCAG 2.1"], "alternates": {"html": "https://wpnews.pro/news/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-to", "markdown": "https://wpnews.pro/news/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-to.md", "text": "https://wpnews.pro/news/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-to.txt", "jsonld": "https://wpnews.pro/news/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-to.jsonld"}}