WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Auto-Generate Image Alt Text on Upload with OpenAI Vision and a Human Review Queue
Advanced WordPress Topic
October 18, 2026

Auto-Generate Image Alt Text on Upload with OpenAI Vision and a Human Review Queue

Auto-Generate Image Alt Text on Upload with OpenAI Vision and a Human Review Queue — photo by Sunriseforever | pixabay:10362988

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 void returns keep the worker readable.
  • An OpenAI API key with access to a vision-capable model. gpt-4o-mini is the sensible default: cheap, fast, good enough for one-sentence descriptions.
  • Working WP-Cron — or better, a real system cron hitting wp-cron.php. If DISABLE_WP_CRON is 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 for DISABLE_WP_CRON in wp-config.php, then confirm your system cron hits wp-cron.php every five minutes. Test manually with wp cron event run wpth_generate_alt_suggestion.
  • Every job ends in error. Log wp_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_large size may not exist for very small uploads — the fallback to the original then sends a big file. Cap the source with wp_get_image_editor() and a 1024px resize before encoding.
  • Duplicate suggestions. Another plugin is firing wp_generate_attachment_metadata more than once. The status guard handles it, but check that nothing is deleting _wpth_alt_status on 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_decorative checkbox 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.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook

Related

Previous Article Building a RAG Search Endpoint for WordPress: Store Post Embeddings and Query Them via REST

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Building a RAG Search Endpoint for WordPress: Store Post Embeddings and Query Them via REST
  • Securing Custom REST API Endpoints with Nonces and Capability Checks

Leave a Reply

Cancel reply

WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed