Automating Featured Images in Drupal 10/11 (Inspired by Substack)

| Drupal & Servers | 5 seen

In a few months, my personal blog will turn 13 years old. Over that time, I’ve published hundreds of articles. For most of it, I followed a simple rule: every article should include a custom image.

At first, that meant manually sourcing visuals. Later, creating them myself. Today, with AI tools, generating images is trivial.

But that leads to a more interesting question: do we actually need a separate image for every article?

I’ve written dozens of posts about custom images in Drupal views. Back in the day, I was particularly eager to follow Drupal’s best practices around media and presentation.

Why images mattered

For me, the answer was never purely visual. It was about distribution: Facebook Open Graph previews, Twitter/X cards, Link sharing

Without an image, posts looked incomplete. With one, they looked credible. That said, social media was never my strongest channel. Most of my traffic historically came from Google SEO. And then… that changed.

From SEO to AI

Search traffic is no longer what it used to be. We’re now in a phase where: AI summarizes content, Users rarely click through, Distribution is fragmented

A few days ago, I asked ChatGPT to evaluate my blog. It got… ambitious. It essentially reframed my personal blog into something resembling an institutional hedge fund publication, suggested Substack subscriptions, and pushed toward a more structured content strategy. Overkill? Maybe. But one idea stuck.

What I really like about Substack is this: It automatically extracts an image from the article content and uses it everywhere - headers, previews, social sharing

No duplication. No extra fields. No friction. That made me question my Drupal setup.

The Drupal problem

In Drupal 10/11, the typical setup looks like this:

  • body field → content with inline images

  • field_images (or similar) → used for:

    • Views listings

    • Featured images

    • Open Graph

That means:

  1. Insert image inline

  2. Upload/select same image again in another field

Or forget step #2 and break your previews. This duplication has been around for years.

Instead of forcing editors to do extra work: Let Drupal extract the first inline image and reuse it automatically. Exactly like Substack.

The solution: a tiny custom module

With a few prompts, ChatGPT helped generate a working Drupal module that:

  • Hooks into node save

  • Parses the body HTML

  • Finds the first inline image

  • Copies it into field_images

No UI changes. No training. No workflow disruption.

Module structure

Create:

 
/modules/custom/blog_first_image_sync/
 

1. blog_first_image_sync.info.yml

 
name: Blog First Image Sync
type: module
description: 'Copies the first inline body image into field_images automatically.'
package: Custom
core_version_requirement: ^10 || ^11
dependencies:
  - drupal:node
  - drupal:file
  - drupal:image
 

2. blog_first_image_sync.module

 
<?php

declare(strict_types=1);

use Drupal\node\NodeInterface;

/**
 * Implements hook_node_presave().
 */
function blog_first_image_sync_node_presave(NodeInterface $node): void {

  // Only act on nodes that have both required fields.
  if (!$node->hasField('body') || !$node->hasField('field_images')) {
    return;
  }

  // Skip empty body.
  if ($node->get('body')->isEmpty()) {
    return;
  }

  $html = (string) $node->get('body')->value;
  if (trim($html) === '') {
    return;
  }

  // Parse HTML safely.
  libxml_use_internal_errors(TRUE);

  $dom = new \DOMDocument();
  $loaded = $dom->loadHTML(
    '<?xml encoding="utf-8" ?><html><body>' . $html . '</body></html>'
  );

  libxml_clear_errors();

  if (!$loaded) {
    return;
  }

  $xpath = new \DOMXPath($dom);

  // Find first inline image with Drupal file reference.
  $nodes = $xpath->query('//img[@data-entity-type="file" and @data-entity-uuid]');

  if (!$nodes || $nodes->length === 0) {
    return;
  }

  $first_image = $nodes->item(0);
  $uuid = $first_image->getAttribute('data-entity-uuid');

  if ($uuid === '') {
    return;
  }

  // Load file entity from UUID.
  $file = \Drupal::service('entity.repository')
    ->loadEntityByUuid('file', $uuid);

  if (!$file) {
    return;
  }

  // Extract optional attributes.
  $alt = $first_image->getAttribute('alt') ?: '';
  $title = $first_image->getAttribute('title') ?: '';

  // Set image field (overwrites existing value).
  $node->set('field_images', [
    [
      'target_id' => $file->id(),
      'alt' => $alt,
      'title' => $title,
    ],
  ]);
}
 

Installation

 
cd /modules/custom
mkdir blog_first_image_sync
# add files
drush en blog_first_image_sync -y
drush cr
 

How it works

On every node save (create or update):

  1. Drupal reads the body HTML

  2. Finds the first inline image

  3. Extracts its data-entity-uuid

  4. Loads the corresponding file entity

  5. Writes it into field_images

Your Views and Open Graph logic continue working unchanged.

Why this works well in Drupal 10/11

Modern Drupal stores inline images like: <img data-entity-uuid="..." data-entity-type="file">. That means:

  • No guessing file paths

  • No brittle parsing

  • Direct mapping to Drupal entities

This is key. Without it, the solution would be much messier.

Result

After enabling:

  • Insert image inline → done

  • Save → auto-populated

  • Views → unchanged

  • Social previews → consistent

No duplication.

The most interesting part wasn’t the code.It was this - with a few prompts, AI helped design and implement a production-ready Drupal feature. Not perfect on first try (wrong content type, small bugs), but fast enough to iterate in minutes instead of hours.

Final thoughts

  • Drupal remains extremely flexible

  • Many long-standing UX issues are solvable with small custom modules

  • AI significantly reduces the cost of building those solutions

It’s been years since I stopped active Drupal development—these days I only use it for my own projects. That said, if you feel you could benefit from some guidance, feel free to reach out to arrange a paid one-on-one coaching session.

Subscription

For $10/month, receive weekly trade ideas and portfolio adjustments directly to your inbox.

I share ongoing portfolio progress with a focus on generating income through covered calls on quality stocks. Each update includes positioning changes, trade rationale, and forward-looking adjustments based on current market conditions.