Back to Blog
Craft CMS AI Automation

Practical AI Integrations for Craft CMS

· 13 min read

There's a lot of hype around AI right now, and a lot of it is noise. But after spending the past year and a half integrating AI into real Craft CMS projects for clients, I can tell you that some of these integrations are genuinely useful. Not in a "replace your content team" way, but in a "save your editors 20 minutes per entry" way.

This post covers the AI integrations I've actually built and shipped. No theoretical stuff. These are patterns running on production sites right now.

Where AI Actually Helps in a CMS

Before jumping into code, let's be realistic about what AI is good at in a CMS context:

  • Repetitive text tasks. Writing alt text, meta descriptions, summaries, and excerpts. Editors hate doing these, and AI does them well enough that editors just need a quick review.
  • Content transformation. Turning a long article into social media posts, email subject lines, or different reading levels.
  • Search enhancement. Making site search understand what users mean, not just what they typed.
  • Content classification. Auto-tagging entries, suggesting categories, flagging content that might be outdated.

What AI is not good at: replacing human writers, making editorial decisions, or anything that requires understanding your brand voice deeply. Treat it as a smart assistant, not an autopilot.

Example 1: Auto-Generate Alt Text for Images

This is the integration clients love the most. When an editor uploads an image to Craft, the module automatically generates descriptive alt text using an AI vision model. The editor can review and adjust it, but 80% of the time the generated text is good enough to use as-is.

modules/sitemodule/services/AltTextService.php
<?php

namespace modules\sitemodule\services;

use Craft;
use craft\elements\Asset;
use yii\base\Component;

class AltTextService extends Component
{
    public function generateAltText(Asset $asset): ?string
    {
        if (!$asset->kind === 'image') {
            return null;
        }

        $imageUrl = $asset->getUrl();
        if (!$imageUrl) {
            return null;
        }

        try {
            $client = Craft::createGuzzleClient();
            $response = $client->post('https://api.openai.com/v1/chat/completions', [
                'headers' => [
                    'Authorization' => 'Bearer ' . getenv('OPENAI_API_KEY'),
                ],
                'json' => [
                    'model' => 'gpt-4o-mini',
                    'messages' => [
                        [
                            'role' => 'user',
                            'content' => [
                                [
                                    'type' => 'text',
                                    'text' => 'Write a concise, descriptive alt text for this image. Keep it under 125 characters. Describe what is visually present without making assumptions. Do not start with "Image of" or "Photo of".',
                                ],
                                [
                                    'type' => 'image_url',
                                    'image_url' => ['url' => $imageUrl],
                                ],
                            ],
                        ],
                    ],
                    'max_tokens' => 100,
                ],
                'timeout' => 15,
            ]);

            $data = json_decode($response->getBody(), true);
            return $data['choices'][0]['message']['content'] ?? null;

        } catch (\Throwable $e) {
            Craft::error("Alt text generation failed: {$e->getMessage()}", __METHOD__);
            return null;
        }
    }
}

Hook it up to Craft's asset save event:

In your module's init()
use craft\elements\Asset;
use craft\events\ModelEvent;

Event::on(
    Asset::class,
    Asset::EVENT_BEFORE_SAVE,
    function (ModelEvent $event) {
        /** @var Asset $asset */
        $asset = $event->sender;

        // Only run on new image uploads that don't have alt text
        if (
            $asset->firstSave
            && $asset->kind === 'image'
            && empty($asset->alt)
        ) {
            $altText = $this->altText->generateAltText($asset);
            if ($altText) {
                $asset->alt = $altText;
            }
        }
    }
);

The $asset->firstSave check is important. You only want to generate alt text on the initial upload, not every time the asset is saved. And you skip assets that already have alt text so you don't overwrite something an editor wrote manually.

Use a cheap, fast model for this kind of task. GPT-4o-mini or Claude Haiku are more than capable for alt text generation, and they respond in under a second. You don't need the most expensive model for every integration.

Example 2: Meta Description Generator

Every SEO audit I've ever done has flagged missing or duplicate meta descriptions. This module generates a meta description from the entry's content whenever an editor saves an entry without one.

modules/sitemodule/services/MetaService.php
<?php

namespace modules\sitemodule\services;

use Craft;
use craft\elements\Entry;
use yii\base\Component;

class MetaService extends Component
{
    public function generateMetaDescription(Entry $entry): ?string
    {
        // Pull the main content from the entry
        $content = strip_tags((string) ($entry->getFieldValue('body') ?? ''));
        if (strlen($content) < 50) {
            return null;
        }

        // Truncate to a reasonable input size
        $content = mb_substr($content, 0, 2000);

        try {
            $client = Craft::createGuzzleClient();
            $response = $client->post('https://api.anthropic.com/v1/messages', [
                'headers' => [
                    'x-api-key' => getenv('ANTHROPIC_API_KEY'),
                    'anthropic-version' => '2023-06-01',
                    'Content-Type' => 'application/json',
                ],
                'json' => [
                    'model' => 'claude-haiku-4-5-20251001',
                    'max_tokens' => 200,
                    'messages' => [
                        [
                            'role' => 'user',
                            'content' => "Write a meta description for a web page with this content. Keep it between 140-160 characters. Make it compelling and include a call to action if appropriate. Do not use quotes.\n\nTitle: {$entry->title}\n\nContent: {$content}",
                        ],
                    ],
                ],
                'timeout' => 10,
            ]);

            $data = json_decode($response->getBody(), true);
            return $data['content'][0]['text'] ?? null;

        } catch (\Throwable $e) {
            Craft::error("Meta description generation failed: {$e->getMessage()}", __METHOD__);
            return null;
        }
    }
}

You'd wire this to the entry save event, checking if the meta description field (from SEOmatic or your own field) is empty. The key detail here is that you only generate when the field is blank. If an editor writes their own meta description, you leave it alone.

Example 3: AI-Enhanced Site Search

Standard Craft search is keyword-based. It works, but it doesn't understand intent. A user searching for "how to reset my password" won't match an entry titled "Account Recovery Guide" because none of the words overlap.

One approach I've used is generating search embeddings for each entry and storing them, then using vector similarity for search queries. But that's a heavy lift. A simpler approach is to use AI to expand the search query:

modules/sitemodule/services/SearchService.php
<?php

namespace modules\sitemodule\services;

use Craft;
use craft\elements\Entry;
use yii\base\Component;

class SearchService extends Component
{
    /**
     * Expand a search query with synonyms and related terms,
     * then run a Craft element query with the expanded terms.
     */
    public function smartSearch(string $query, int $limit = 10): array
    {
        $expandedTerms = $this->expandQuery($query);

        // Combine original query with expanded terms
        $searchString = $query . ' OR ' . implode(' OR ', $expandedTerms);

        return Entry::find()
            ->search($searchString)
            ->limit($limit)
            ->all();
    }

    private function expandQuery(string $query): array
    {
        try {
            $client = Craft::createGuzzleClient();
            $response = $client->post('https://api.anthropic.com/v1/messages', [
                'headers' => [
                    'x-api-key' => getenv('ANTHROPIC_API_KEY'),
                    'anthropic-version' => '2023-06-01',
                    'Content-Type' => 'application/json',
                ],
                'json' => [
                    'model' => 'claude-haiku-4-5-20251001',
                    'max_tokens' => 100,
                    'messages' => [
                        [
                            'role' => 'user',
                            'content' => "Given this search query, return 3-5 alternative search terms or synonyms that someone might mean. Return only the terms, one per line, no numbering.\n\nQuery: $query",
                        ],
                    ],
                ],
                'timeout' => 5,
            ]);

            $data = json_decode($response->getBody(), true);
            $text = $data['content'][0]['text'] ?? '';

            return array_filter(
                array_map('trim', explode("\n", $text))
            );

        } catch (\Throwable $e) {
            // If AI fails, just return the original query
            Craft::warning("Search expansion failed: {$e->getMessage()}", __METHOD__);
            return [];
        }
    }
}
Adding an API call to every search request adds latency. For high-traffic sites, cache the expanded queries so the same search term doesn't hit the API twice. A simple database or Redis cache with a 24-hour TTL works well for this.

Example 4: Content Freshness Checker

This one runs as a console command (via cron) and flags entries that might be outdated. It checks the content against the current date and looks for things like outdated statistics, dead links, or references to past events.

modules/sitemodule/console/controllers/ContentAuditController.php
<?php

namespace modules\sitemodule\console\controllers;

use Craft;
use craft\console\Controller;
use craft\elements\Entry;
use yii\console\ExitCode;

class ContentAuditController extends Controller
{
    public function actionFreshness(): int
    {
        $entries = Entry::find()
            ->section('blog')
            ->orderBy('dateUpdated ASC')
            ->limit(20)
            ->all();

        $this->stdout("Checking " . count($entries) . " oldest entries...\n\n");

        $client = Craft::createGuzzleClient();
        $flagged = [];

        foreach ($entries as $entry) {
            $content = strip_tags((string) ($entry->getFieldValue('body') ?? ''));
            if (strlen($content) < 100) continue;

            $content = mb_substr($content, 0, 3000);

            try {
                $response = $client->post('https://api.anthropic.com/v1/messages', [
                    'headers' => [
                        'x-api-key' => getenv('ANTHROPIC_API_KEY'),
                        'anthropic-version' => '2023-06-01',
                        'Content-Type' => 'application/json',
                    ],
                    'json' => [
                        'model' => 'claude-haiku-4-5-20251001',
                        'max_tokens' => 200,
                        'messages' => [
                            [
                                'role' => 'user',
                                'content' => "Today is " . date('Y-m-d') . ". Review this blog post and identify any content that appears outdated (old dates, deprecated technologies, statistics that are likely stale). If nothing seems outdated, respond with just 'OK'. Otherwise briefly list what looks outdated.\n\nTitle: {$entry->title}\nLast updated: {$entry->dateUpdated->format('Y-m-d')}\n\nContent: {$content}",
                            ],
                        ],
                    ],
                    'timeout' => 15,
                ]);

                $data = json_decode($response->getBody(), true);
                $result = trim($data['content'][0]['text'] ?? 'OK');

                if (strtoupper($result) !== 'OK') {
                    $flagged[] = [
                        'entry' => $entry,
                        'issues' => $result,
                    ];
                    $this->stdout("FLAGGED: {$entry->title}\n");
                    $this->stdout("  Issues: {$result}\n\n");
                } else {
                    $this->stdout("OK: {$entry->title}\n");
                }

            } catch (\Throwable $e) {
                $this->stderr("Error checking {$entry->title}: {$e->getMessage()}\n");
            }
        }

        $this->stdout("\n" . count($flagged) . " entries flagged for review.\n");
        return ExitCode::OK;
    }
}
Terminal / crontab
# Run weekly content freshness audit
0 9 * * 1 cd /path/to/project && php craft site-module/content-audit/freshness

This has been surprisingly useful for clients with large blogs. It catches things like "As of 2023..." statements, references to deprecated APIs, and statistics that are clearly from years ago. The editorial team gets a weekly email with the flagged entries and can decide what to update.

Example 5: Auto-Tagging Entries

If your site has a category or tag system and editors frequently forget to tag their content, AI can suggest tags based on the content. This is a lightweight integration that adds suggestions to the control panel.

The simplest approach is a custom Twig extension that editors can use in a dashboard widget:

Twig extension for tag suggestions
public function suggestTags(Entry $entry, array $existingTags): array
{
    $content = strip_tags((string) ($entry->getFieldValue('body') ?? ''));
    $tagNames = array_map(fn($tag) => $tag->title, $existingTags);

    $client = Craft::createGuzzleClient();
    $response = $client->post('https://api.anthropic.com/v1/messages', [
        'headers' => [
            'x-api-key' => getenv('ANTHROPIC_API_KEY'),
            'anthropic-version' => '2023-06-01',
            'Content-Type' => 'application/json',
        ],
        'json' => [
            'model' => 'claude-haiku-4-5-20251001',
            'max_tokens' => 100,
            'messages' => [
                [
                    'role' => 'user',
                    'content' => "Given this blog post, suggest 3-5 tags from this list. Only suggest tags from the list, do not invent new ones. Return one per line.\n\nAvailable tags: " . implode(', ', $tagNames) . "\n\nPost title: {$entry->title}\nContent: " . mb_substr($content, 0, 2000),
                ],
            ],
        ],
    ]);

    $data = json_decode($response->getBody(), true);
    $suggestions = $data['content'][0]['text'] ?? '';

    return array_filter(
        array_map('trim', explode("\n", $suggestions))
    );
}

The key trick is constraining the AI to your existing tag list. You don't want it inventing new tags that don't exist in your taxonomy. By passing the full list of available tags and explicitly saying "only suggest from this list," you get suggestions that can be applied directly.

Architecture Considerations

Don't Block the Save

For any AI integration that runs on entry save, consider whether it needs to happen synchronously. Alt text generation is fast enough to run inline (under 2 seconds). But something like content analysis or multi-step transformations should go through Craft's queue system.

Queue job pattern
// In your event handler
Event::on(Entry::class, Entry::EVENT_AFTER_SAVE, function(ModelEvent $event) {
    $entry = $event->sender;

    if ($entry->section?->handle === 'blog' && !$entry->getIsDraft()) {
        Craft::$app->queue->push(new GenerateMetaJob([
            'entryId' => $entry->id,
        ]));
    }
});

API Keys and Costs

Store API keys in your .env file, never in code or config files. And keep an eye on costs. These integrations use cheap models, but if you have a site with 10,000 entries and you accidentally trigger a regeneration on all of them, the bill adds up.

I always add guards:

  • Only run on first save or when the field is empty
  • Skip drafts and revisions
  • Add a daily API call limit as a safety net
  • Log every API call so you can audit costs

Fallback Gracefully

Every AI integration should work when the API is down. The site shouldn't break because OpenAI is having an outage. Every API call is wrapped in a try/catch, and the fallback is always "do nothing" or "use the default behavior."

I treat AI integrations as enhancements, never dependencies. The site must function perfectly without them. If the AI generates bad alt text, the editor can fix it. If the meta description generator fails, the field stays blank and the editor writes one manually. Nothing breaks.

What's Next

The integrations I'm most excited about right now are:

  • AI-powered content translation. Automatically translating entries for multi-site setups, with human review before publishing.
  • Conversational site search. Letting visitors ask questions in natural language and getting answers sourced from site content.
  • Content brief generation. Analyzing existing content gaps and generating writing briefs for the editorial team.

The tools are getting better and cheaper fast. What was expensive and slow a year ago is now dirt cheap and instant. The key is finding the right places to apply it, spots where editors are doing repetitive work that AI can handle with human oversight.


If you're curious about adding AI features to your Craft site, or you've got a specific workflow you think AI could improve, let's talk about it. This is the kind of work I genuinely enjoy figuring out.