,

Building a WordPress Plugin with Gemini: Full Walkthrough

A complete, working WordPress plugin that uses Gemini's multimodal vision to generate image alt text on upload, with proper security, caching, graceful degradation, and WordPress.org-review-friendly architecture.

The best way to learn an AI API is to ship one small, complete, useful thing. In this walkthrough we’ll build a real plugin: automatic alt text for images, generated by Gemini’s vision capability at upload time. It’s a genuinely useful feature (alt text is the most neglected accessibility task in WordPress) and it exercises the skills that transfer to every other AI feature: multimodal input, hooks, caching, and safe failure.

The full flow: user uploads an image → WordPress fires our hook → we send the image to Gemini → Gemini describes it → we save the description as alt text. The editor can always override it; we never touch an alt text a human already wrote.

Plugin skeleton

gemini-alt-text/
├── gemini-alt-text.php      # bootstrap + hooks
├── includes/
│   ├── class-generator.php  # talks to Gemini
│   └── class-settings.php   # admin settings page
└── readme.txt

The bootstrap file:

<?php
/**
 * Plugin Name: Gemini Alt Text
 * Description: Generates image alt text automatically using Google Gemini.
 * Version: 1.0.0
 * Requires at least: 6.5
 * Requires PHP: 8.0
 * License: GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

require_once __DIR__ . '/includes/class-generator.php';
require_once __DIR__ . '/includes/class-settings.php';

add_action( 'add_attachment', 'gat_maybe_generate_alt_text' );

function gat_maybe_generate_alt_text( int $attachment_id ): void {
    if ( ! wp_attachment_is_image( $attachment_id ) ) {
        return;
    }
    // Never overwrite human-written alt text.
    if ( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) {
        return;
    }
    // Don't block the upload request; do the work in the background.
    wp_schedule_single_event( time(), 'gat_generate_event', [ $attachment_id ] );
}

add_action( 'gat_generate_event', function ( int $attachment_id ): void {
    $alt = GAT_Generator::describe_image( $attachment_id );
    if ( is_string( $alt ) && '' !== $alt ) {
        update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) );
    }
} );

Two decisions here are worth internalizing because they apply to every AI plugin:

  • Background, not inline. AI calls take 1–10 seconds. Blocking the media upload request on one is how you earn one-star reviews. wp_schedule_single_event() gets it out of the request cycle with zero extra infrastructure.
  • Respect human input. Checking for existing alt text isn’t just politeness: it’s the difference between a tool and a nuisance.

The Gemini call: multimodal input

Gemini accepts images as base64-encoded inlineData parts, side by side with a text part carrying the instruction. This is the same parts-in/parts-out shape as plain text generation: multimodal is not a different API, just a different part type:

class GAT_Generator {

    public static function describe_image( int $attachment_id ): string|WP_Error {
        $path = get_attached_file( $attachment_id );
        if ( ! $path || ! file_exists( $path ) ) {
            return new WP_Error( 'gat_no_file', 'Attachment file not found.' );
        }

        // Use a resized version: the model doesn't need 8MB originals,
        // and smaller payloads are faster and cheaper.
        $sized = image_get_intermediate_size( $attachment_id, 'large' );
        if ( $sized ) {
            $upload_dir = wp_get_upload_dir();
            $candidate  = $upload_dir['basedir'] . '/' . $sized['path'];
            if ( file_exists( $candidate ) ) {
                $path = $candidate;
            }
        }

        $mime = get_post_mime_type( $attachment_id );
        if ( ! in_array( $mime, [ 'image/jpeg', 'image/png', 'image/webp' ], true ) ) {
            return new WP_Error( 'gat_bad_type', 'Unsupported image type.' );
        }

        $body = [
            'contents'         => [ [
                'parts' => [
                    [ 'text' => 'Write concise alt text for this image for a website. '
                        . 'Describe what is visually present in one sentence under 125 characters. '
                        . 'No "image of" or "photo of" prefix. Output only the alt text.' ],
                    [ 'inlineData' => [
                        'mimeType' => $mime,
                        'data'     => base64_encode( file_get_contents( $path ) ),
                    ] ],
                ],
            ] ],
            'generationConfig' => [ 'temperature' => 0.2, 'maxOutputTokens' => 100 ],
        ];

        $response = wp_remote_post(
            'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent',
            [
                'timeout' => 60,
                'headers' => [
                    'Content-Type'   => 'application/json',
                    'x-goog-api-key' => GAT_Settings::api_key(),
                ],
                'body'    => wp_json_encode( $body ),
            ]
        );

        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        return trim( $data['candidates'][0]['content']['parts'][0]['text'] ?? '' );
    }
}

Notice the prompt engineering is doing real work: the 125-character limit and the “no prefix” rule come straight from accessibility guidelines. Encode your domain knowledge in the prompt: that’s where an AI feature gets its quality.

Settings, done the boring right way

The settings class registers one field (the API key) using the Settings API, sanitizes it on save, and exposes it via GAT_Settings::api_key(), preferring a GAT_GEMINI_KEY constant from wp-config.php when defined. Standard stuff, but three review-critical details:

  • Sanitize on input (sanitize_text_field), escape on output (esc_attr in the field value).
  • Capability-check the settings page with manage_options.
  • If no key is configured, show one dismissible admin notice and no-op cleanly. A plugin that fatals or nags without a key won’t survive WordPress.org review, or users.

Note for the WordPress 7.0 era

I’ve shown direct API calls so the mechanics are visible, and so this works on WordPress 6.x. On WordPress 7.0+, the better pattern is to route through core’s AI client (previous post in this series): replace the wp_remote_post() call with wp_ai_client_prompt(), drop your own key storage entirely, and let the site’s configured provider handle credentials. The hook architecture, background processing, caching, and prompt design all stay identical. Supporting both is easy: function_exists( 'wp_ai_client_prompt' ) is your feature detection.

Cost control

Alt text generation is cheap per image, but a bulk upload of 500 photos is a real bill. Ship with:

  • A per-hash cache: md5 of the file contents → generated alt text, stored as a transient, so re-uploads and regenerations are free.
  • A daily cap (a counter in an option, reset by cron) with a filter so power users can raise it: apply_filters( 'gat_daily_limit', 200 ).

Continue the Gemini and WordPress series: build a WordPress plugin with Gemini, read the complete Gemini and WordPress developer guide, or learn how to secure Gemini API keys in WordPress.

Frequently asked questions

What should a Gemini WordPress plugin include before launch?

At minimum, validate settings, keep the API key out of the browser, use the WordPress HTTP API, handle timeouts and provider errors, cap output, and escape or sanitize anything rendered back into WordPress.

Can a WordPress plugin send images or documents to Gemini?

Yes, when the selected model and endpoint support the input type. Send the file or an approved remote representation from the server, enforce size and MIME limits, and avoid forwarding private documents without a clear user action and retention policy.

How can a Gemini plugin control API cost?

Use the cheapest suitable model, cap output tokens, cache repeatable results, limit requests per user and log status and latency without logging prompts that contain sensitive data. Measure before increasing limits.

Should a Gemini API key be exposed in JavaScript?

No. Browser code can be inspected by every visitor. Keep the key on the server and make the browser call a narrowly scoped, authenticated WordPress endpoint with nonce, capability, rate and input checks.

Ship it

Test the unhappy paths before the happy one: no API key set, an invalid key, a rate-limit response, an SVG upload, a 20MB TIFF. If your plugin behaves politely in all five cases, you’ve cleared a bar most AI plugins don’t.

Next in the series: semantic search with Gemini embeddings, moving beyond generation into making WordPress content findable by meaning.

Aditya Shah Avatar

Written by

Hosting Support Manager & DevOps Engineer at WPMU DEV. I scale and secure WordPress infrastructure, and organize WordPress Bhopal and GDG Cloud Bhopal.

More about me · LinkedIn · GitHub