The shape of the solution
Adding alt text with a vision model looks trivial: send the image, get a sentence, save it. The difficulty is everything surrounding that sentence. Uploads are synchronous, so a two-second API round trip inside wp_generate_attachment_metadata will stall the media modal and, on shared hosting, blow past max_execution_time. Model output is unpredictable, so it cannot go straight to the front end. And an unbounded loop over a 4,000-image library will quietly cost real money.
So the build has four parts:
- A queue — hook the upload, write a status flag, schedule a single event.
- A worker — a cron job that picks a resized file, calls the API, and stores the suggestion as
pending. - A budget — a per-site monthly token cap that fails closed.
- A review queue — a Media Library column plus a bulk-approve screen, so nothing reaches the front end until a human has read it.
Nothing here writes to _wp_attachment_image_alt until approval. That key is what core reads when it renders <img alt="...">, so leaving it untouched is the entire safety mechanism. The suggestion lives in a separate meta key and is inert until promoted.
Prerequisites
- WordPress 6.4 or later. You will want registered post meta if you plan to expose the suggestion over REST later.
- PHP 8.1+. Typed parameters and
voidreturns keep the worker readable. - An OpenAI API key with access to a vision-capable model.
gpt-4o-miniis the sensible default: cheap, fast, good enough for one-sentence descriptions. - Working WP-Cron — or better, a real system cron hitting
wp-cron.php. IfDISABLE_WP_CRONis set and nothing calls it, your queue fills silently and never drains. - WP-CLI, so you can trigger the worker without uploading files by hand.
Keep the key out of the database. Define it in wp-config.php:
define( 'WPTH_OPENAI_KEY', 'sk-...' );
Step 1 — Queue the job instead of calling the API
wp_generate_attachment_metadata fires inside the upload request, immediately after the resized versions are written. Anything slow here is felt directly by the person uploading — the spinner hangs, the modal sits there, and on a slow host the request dies halfway through. Scheduling a single event moves the work to a separate request entirely.
<?php
add_action( 'wp_generate_attachment_metadata', 'wpth_queue_alt_suggestion', 20, 2 );
function wpth_queue_alt_suggestion( array $metadata, int $attachment_id ) : void {
if ( ! wp_attachment_is_image( $attachment_id ) ) {
return;
}
if ( apply_filters( 'wpth_skip_alt_suggestion', false, $attachment_id ) ) {
return;
}
if ( get_post_meta( $attachment_id, '_wpth_alt_status', true ) ) {
return; // already queued, pending or approved
}
update_post_meta( $attachment_id, '_wpth_alt_status', 'queued' );
wp_schedule_single_event(
time() + 30,
'wpth_generate_alt_suggestion',
array( $attachment_id )
);
}
The 30-second delay is deliberate. It lets the upload request finish, lets the metadata settle, and avoids competing with other cron work in the same window. The status flag is the idempotency guard: if the hook fires twice for the same attachment — which happens with some offload plugins — the second call returns early.
Note the priority of 20. Core registers its own callbacks on this hook, and running after them means the intermediate sizes definitely exist when the worker looks for them.
Step 2 — The background worker
The worker does five things in order: confirm the job is still wanted, check the budget, pick a resized file, call the API, sanitise and store. Any failure writes a terminal status so the same attachment is not retried forever.
<?php
add_action( 'wpth_generate_alt_suggestion', 'wpth_run_alt_suggestion' );
function wpth_run_alt_suggestion( int $attachment_id ) : void {
if ( 'queued' !== get_post_meta( $attachment_id, '_wpth_alt_status', true ) ) {
return;
}
if ( wpth_monthly_spend_exceeded() ) {
update_post_meta( $attachment_id, '_wpth_alt_status', 'skipped_budget' );
return;
}
$original = get_attached_file( $attachment_id );
$sized = image_get_intermediate_size( $attachment_id, 'medium_large' );
$path = $sized
? trailingslashit( wp_get_upload_dir()['basedir'] ) . $sized['path']
: $original;
if ( ! $path || ! is_readable( $path ) ) {
update_post_meta( $attachment_id, '_wpth_alt_status', 'error' );
return;
}
$payload = array(
'model' => 'gpt-4o-mini',
'max_tokens' => 80,
'messages' => array(
array(
'role' => 'system',
'content' => 'Write one sentence of alt text, under 125 characters. Describe what the image shows and why it matters here. Never begin with "Image of" or "Picture of". No quotation marks.',
),
array(
'role' => 'user',
'content' => array(
array( 'type' => 'text', 'text' => 'Describe this image for a screen reader.' ),
array(
'type' => 'image_url',
'image_url' => array(
'url' => 'data:image/jpeg;base64,' . base64_encode( (string) file_get_contents( $path ) ),
),
),
),
),
),
);
$response = wp_remote_post(
'https://api.openai.com/v1/chat/completions',
array(
'timeout' => 45,
'headers' => array(
'Authorization' => 'Bearer ' . wpth_openai_key(),
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( $payload ),
)
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
update_post_meta( $attachment_id, '_wpth_alt_status', 'error' );
return;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$text = sanitize_text_field(
trim( (string) ( $body['choices'][0]['message']['content'] ?? '' ), " \t\n\r\0\x0B\"'" )
);
if ( '' === $text ) {
update_post_meta( $attachment_id, '_wpth_alt_status', 'error' );
return;
}
update_post_meta( $attachment_id, '_wpth_alt_suggestion', $text );
update_post_meta( $attachment_id, '_wpth_alt_status', 'pending' );
wpth_record_spend( (int) ( $body['usage']['total_tokens'] ?? 0 ) );
}
Three details are worth pausing on. First, medium_large is used rather than the original: a 6000px JPEG base64-encoded is roughly 8MB of request body, and the model downsamples it anyway. Second, the prompt forbids “image of” because vision models default to it and it is pure noise for a screen reader. Third, the response is passed through sanitize_text_field with surrounding quotes stripped — models frequently wrap the sentence in quotation marks.
If your site is public and you would rather send a URL than a payload, swap the image_url value for wp_get_attachment_image_url( $attachment_id, 'medium_large' ). Remember that OpenAI’s servers must be able to fetch it, which rules out local development and most password-protected staging sites.
Step 3 — Budget cap and the skip filter
The cap is expressed in tokens rather than pounds or dollars, because token pricing changes and you do not want a hard-coded price table rotting in your plugin. Everything fails closed: if the cap is reached, the job is marked skipped_budget and the attachment stays in the queue for a human to handle manually.
<?php
function wpth_openai_key() : string {
return (string) apply_filters(
'wpth_openai_api_key',
defined( 'WPTH_OPENAI_KEY' ) ? WPTH_OPENAI_KEY : ''
);
}
function wpth_current_month_tokens() : int {
return (int) get_option( 'wpth_tokens_' . gmdate( 'Ym' ), 0 );
}
function wpth_monthly_spend_exceeded() : bool {
$cap = (int) apply_filters( 'wpth_monthly_token_cap', 250000 );
return wpth_current_month_tokens() >= $cap;
}
function wpth_record_spend( int $tokens ) : void {
if ( $tokens < 1 ) {
return;
}
update_option( 'wpth_tokens_' . gmdate( 'Ym' ), wpth_current_month_tokens() + $tokens, false );
}
add_filter( 'wpth_skip_alt_suggestion', function ( bool $skip, int $attachment_id ) : bool {
if ( $skip ) {
return true;
}
$mime = (string) get_post_mime_type( $attachment_id );
if ( ! in_array( $mime, array( 'image/jpeg', 'image/png', 'image/webp' ), true ) ) {
return true; // SVG, GIF, PDF previews — out of scope
}
if ( get_post_meta( $attachment_id, '_wpth_decorative', true ) ) {
return true;
}
$file = basename( (string) get_attached_file( $attachment_id ) );
return (bool) preg_match( '/^(icon|divider|spacer|bg|pattern|swatch)[-_]/i', $file );
}, 10, 2 );
250,000 tokens is roughly 300–400 images at medium_large resolution with a short completion — adjust with the filter. The counter is a non-autoloaded option keyed by month, so it never loads on a front-end request and old months roll off naturally.
Caveat: get_option followed by update_option is not atomic. Two workers finishing at the same instant can lose a few tokens. For a media library that is acceptable; if you need exact accounting, write to a custom table with INSERT ... ON DUPLICATE KEY UPDATE.
The skip filter runs at queue time, not worker time, so decorative images never consume a slot. Filename matching is crude but effective — most theme assets are named divider-01.png or icon-search.svg. Add a checkbox on the attachment edit screen writing _wpth_decorative for the cases the filename misses.
Step 4 — Surface the queue in the Media Library
A queue nobody looks at is a queue that never drains. A column in list mode is the cheapest possible interface: it shows the status, a preview of the suggestion, and whether anything has actually been published.
<?php
add_filter( 'manage_media_columns', function ( array $columns ) : array {
$columns['wpth_alt'] = __( 'Alt text', 'wpth' );
return $columns;
} );
add_action( 'manage_media_custom_column', function ( string $column, int $attachment_id ) : void {
if ( 'wpth_alt' !== $column ) {
return;
}
$status = (string) get_post_meta( $attachment_id, '_wpth_alt_status', true );
$suggestion = (string) get_post_meta( $attachment_id, '_wpth_alt_suggestion', true );
$published = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
if ( 'pending' === $status && '' !== $suggestion ) {
printf(
'<span class="wpth-badge wpth-badge--pending">%s</span><br><em>%s</em>',
esc_html__( 'Pending review', 'wpth' ),
esc_html( wp_trim_words( $suggestion, 12 ) )
);
return;
}
if ( '' !== $published ) {
printf(
'<span class="wpth-badge wpth-badge--ok">%s</span>',
esc_html__( 'Published', 'wpth' )
);
return;
}
printf(
'<span class="wpth-badge">%s</span>',
esc_html( '' !== $status ? $status : __( 'None', 'wpth' ) )
);
}, 10, 2 );
Everything is escaped with esc_html on output. The suggestion is model-generated text and should be treated as untrusted input, exactly like a comment body — it has been through sanitize_text_field on the way in, and it is escaped again on the way out.
Step 5 — The bulk approve screen
Editing fifty suggestions one attachment at a time is miserable. A single screen with a text input per row, a nonce, and a capability check turns a chore into a two-minute job.
<?php
add_action( 'admin_menu', function () : void {
add_media_page(
__( 'Alt Text Review', 'wpth' ),
__( 'Alt Text Review', 'wpth' ),
'edit_others_posts',
'wpth-alt-review',
'wpth_render_alt_review_page'
);
} );
function wpth_render_alt_review_page() : void {
if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_die( esc_html__( 'You cannot review alt text.', 'wpth' ) );
}
if ( isset( $_POST['wpth_alt_review'] ) ) {
check_admin_referer( 'wpth_alt_review' );
foreach ( (array) ( $_POST['wpth_alt'] ?? array() ) as $id => $alt ) {
$id = absint( $id );
if ( ! $id || ! current_user_can( 'edit_post', $id ) ) {
continue;
}
update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( wp_unslash( $alt ) ) );
update_post_meta( $id, '_wpth_alt_status', 'approved' );
}
echo '<div class="notice notice-success is-dismissible"><p>'
. esc_html__( 'Alt text saved.', 'wpth' )
. '</p></div>';
}
$pending = get_posts( array(
'post_type' => 'attachment',
'post_mime_type' => 'image',
'post_status' => 'inherit',
'posts_per_page' => 50,
'meta_key' => '_wpth_alt_status',
'meta_value' => 'pending',
'orderby' => 'date',
'order' => 'DESC',
) );
echo '<div class="wrap"><h1>' . esc_html__( 'Alt Text Review', 'wpth' ) . '</h1>';
if ( ! $pending ) {
echo '<p>' . esc_html__( 'Nothing waiting for review.', 'wpth' ) . '</p></div>';
return;
}
echo '<form method="post">';
wp_nonce_field( 'wpth_alt_review' );
echo '<table class="widefat striped"><thead><tr><th>'
. esc_html__( 'Image', 'wpth' ) . '</th><th>'
. esc_html__( 'Suggested alt text', 'wpth' )
. '</th></tr></thead><tbody>';
foreach ( $pending as $item ) {
printf(
'<tr><td>%s</td><td><input type="text" class="large-text" name="wpth_alt[%d]" value="%s"></td></tr>',
wp_get_attachment_image( $item->ID, 'thumbnail' ),
(int) $item->ID,
esc_attr( (string) get_post_meta( $item->ID, '_wpth_alt_suggestion', true ) )
);
}
echo '</tbody></table>';
submit_button( __( 'Approve selected', 'wpth' ), 'primary', 'wpth_alt_review' );
echo '</form></div>';
}
The reviewer edits the text in place, so a bad suggestion costs a few keystrokes rather than a rejection workflow. wp_get_attachment_image() is used for the thumbnail because it returns a fully-formed, escaped <img> — do not hand-build the markup.
Fifty rows per page is a deliberate limit. Larger pages make the query slow and the screen unwieldy; if you have hundreds pending, add pagination with paginate_links() rather than raising the number.
Step 6 — Keep the front end clean
Because the worker writes to _wpth_alt_suggestion and never to _wp_attachment_image_alt, nothing unapproved can reach the front end. Core reads the latter key when it builds the alt attribute, so the separation is the guarantee.
One safety net is still worth adding: if a reviewer approves an image but clears the text, fall back to the original suggestion rather than shipping an empty alt on a content image.
<?php
add_filter( 'wp_get_attachment_image_attributes', function ( array $attr, $attachment ) : array {
if ( ! empty( $attr['alt'] ) ) {
return $attr;
}
if ( 'approved' === get_post_meta( $attachment->ID, '_wpth_alt_status', true ) ) {
$attr['alt'] = (string) get_post_meta( $attachment->ID, '_wpth_alt_suggestion', true );
}
return $attr;
}, 10, 2 );
If you later expose the suggestion in the block editor, register the meta with show_in_rest and a sanitize_callback, then read it through the data layer — never by injecting raw REST HTML into the DOM.
Troubleshooting
- Nothing ever leaves
queued. WP-Cron is not running. Check forDISABLE_WP_CRONinwp-config.php, then confirm your system cron hitswp-cron.phpevery five minutes. Test manually withwp cron event run wpth_generate_alt_suggestion. - Every job ends in
error. Logwp_remote_retrieve_response_code()and the body. A 401 means the key is missing or the constant is defined after the plugin loads; a 429 means rate limiting and you should add a backoff before rescheduling. - Timeouts on large PNGs. The
medium_largesize may not exist for very small uploads — the fallback to the original then sends a big file. Cap the source withwp_get_image_editor()and a 1024px resize before encoding. - Duplicate suggestions. Another plugin is firing
wp_generate_attachment_metadatamore than once. The status guard handles it, but check that nothing is deleting_wpth_alt_statuson save. - Alt text missing in the block editor. The editor reads
_wp_attachment_image_alt; refresh the media modal after approving. If it still shows nothing, confirm the meta was written with a string, not an array. - Budget cap hit on day two. Your library probably contains many decorative assets. Tighten the filename regex and add the
_wpth_decorativecheckbox to the attachment edit screen.
Next steps
- Add a WP-CLI command —
wp wpth backfill --limit=200— that queues existing attachments in batches so you can process a legacy library without touching the upload flow. - Register the suggestion meta with block bindings so a custom block can display the pending text in the editor as a hint, while the rendered output still uses the approved value.
- Log every call to a custom table with attachment ID, token count, model, and timestamp. The monthly option is fine for a cap; a table is what you want when someone asks where the money went.
- Extend the review screen with a “reject” action that clears the suggestion and sets
_wpth_decorative, so genuinely decorative images never re-enter the queue. - Wire the same worker to a caption suggestion, stored under a different meta key and reviewed on the same screen.
Leave a Reply