Inserting "ALT Tag" with content from Lightroom's metadata IPTC Accessibility

In my conversation with ChapGPT, I noted it advised me to use separate content for the ALT tag versus the Caption tag. I gave it the code for PHPPlugins and asked if it saw an opportunity to incorporate content from the IPTC values from “Accessibility” fields “Alt Text” and “Extended Description” and it said yes.This has been a feature I’ve wanted to do and try for years for the sake of SEO.

I can give you the entire conversation, but the important question that Chat asked was:

You MUST confirm whether Backlight exposes these constants:

Examples (varies by TTG version):

Photo::$IPTC_ALT_TEXT
Photo::$IPTC_DESCRIPTION
Photo::$IPTC_CAPTION
or sometimes:
“IPTC:Alt Text”
“XMP:Description”

:point_right: If those constants don’t exist, you can still pull them via raw metadata keys, but we’d need to inspect TTG’s metadata map.

Chat suggested code like this:

$img_alt =
$photo->hasMetadata(Photo::$IPTC_ALT_TEXT)
? $photo->getMetadata(Photo::$IPTC_ALT_TEXT)
: $photo->getFilename();

$img_caption =
$photo->hasMetadata(Photo::$IPTC_EXTENDED_DESCRIPTION)
? $photo->getMetadata(Photo::$IPTC_EXTENDED_DESCRIPTION)
: ($photo->hasMetadata(Photo::$PHOTO_CAPTION)
? $photo->getMetadata(Photo::$PHOTO_CAPTION)
: ‘’);

What do you think? Can we make this work?

Take a look at the phplugins-pangolin-sample.php file starting at line 509 to see if something like that would do what you need.

Nothing that specifically gives me access to meta that makes sense. You can customize Metadata in LR and include the IPTC fields as described.

Will phpplugin access the following if you ask it to?

$photo->getMetadata(Photo::$IPTC_ALT_TEXT)
$photo->getMetadata(Photo::$IPTC_EXTENDED_DESCRIPTION)

This was what it suggested:

<?php

if (defined('BACKLIGHT_HOOK')) {
	require_once(realpath(BACKLIGHT_HOOK).'/modules/module-designer/application/helpers/APHPlugins.php');
}

class PHPlugins extends APHPlugins
{

/**
 * HEAD (unchanged except safe GA hook preserved)
 */
function head(){
	echo "
	<script>
		var captureOutboundLink = function(url) {
			ga('send', 'event', 'outbound', 'click', url, {
				'transport': 'beacon',
				'hitCallback': function(){document.location = url;}
			});
		}
	</script>
	";
}


/**
 * SAFE METADATA HELPERS (SEO CORE)
 */
private function get_alt_text($photo)
{
	// 1. IPTC Accessibility Alt Text (BEST)
	if ($photo->hasMetadata('IPTC:Alt Text')) {
		return $photo->getMetadata('IPTC:Alt Text');
	}

	// 2. XMP fallback (some Lightroom exports)
	if ($photo->hasMetadata('XMP:Title')) {
		return $photo->getMetadata('XMP:Title');
	}

	// 3. TTG Caption fallback
	if ($photo->hasMetadata(Photo::$PHOTO_CAPTION)) {
		return $photo->getMetadata(Photo::$PHOTO_CAPTION);
	}

	// 4. Filename fallback
	return $photo->getFilename();
}


private function get_caption($photo)
{
	// 1. IPTC Extended Description (BEST for SEO + storytelling)
	if ($photo->hasMetadata('IPTC:Extended Description')) {
		return $photo->getMetadata('IPTC:Extended Description');
	}

	// 2. Standard caption
	if ($photo->hasMetadata(Photo::$PHOTO_CAPTION)) {
		return $photo->getMetadata(Photo::$PHOTO_CAPTION);
	}

	return '';
}


private function esc($text)
{
	return htmlspecialchars($text ?? '', ENT_QUOTES, 'UTF-8');
}


/**
 * ALBUM LOOP OVERRIDE (SEO IMAGE CORE)
 */
function album_top()
{
	if (!$this->hasAlbum()) return true;

	$photos = $this->album->getPhotosForPage();

	foreach ($photos as $photo) {

		$th_path = $photo->getURL('thumbnails');
		$ph_path = $photo->getURL('photos');

		$alt_text = $this->esc($this->get_alt_text($photo));
		$caption  = $this->esc($this->get_caption($photo));
		$title    = $this->esc(
			$photo->hasMetadata(Photo::$PHOTO_TITLE)
				? $photo->getMetadata(Photo::$PHOTO_TITLE)
				: $photo->getFilename()
		);

		$single_url = $photo->getSingleURL();

		echo '<figure class="seo-photo-item">';

		echo '<a href="'.$single_url.'" data-fancybox="gallery">';
		echo '<img src="'.$th_path.'" alt="'.$alt_text.'" title="'.$title.'" loading="lazy" />';
		echo '</a>';

		// Caption only if exists (clean output, better UX)
		if (!empty($caption)) {
			echo '<figcaption>'.$caption.'</figcaption>';
		}

		echo '</figure>';
	}

	return false;
}


/**
 * SINGLE IMAGE SEO ENHANCEMENT (optional but strong)
 */
function single_top()
{
	if (!$this->hasPhoto()) return true;

	$photo = $this->photo;

	$alt_text = $this->esc($this->get_alt_text($photo));
	$caption  = $this->esc($this->get_caption($photo));
	$title    = $this->esc($photo->getFilename());

	echo '<div class="seo-single-image">';

	echo '<img src="'.$photo->getURL('photos').'" alt="'.$alt_text.'" title="'.$title.'" />';

	if (!empty($caption)) {
		echo '<div class="seo-caption">'.$caption.'</div>';
	}

	echo '</div>';

	return true;
}

}
?>

I asked it to merge the suggested code into the existing and here’s what it gave me. Does this look right to you? At the end of the code, read the commentary it provided.

<?php
/*
 * TTG PHPlugins – Production SEO + Accessibility Upgrade
 * Merged with original Pangolin PHPlugins
 */

/* ---------------------------------------
   INIT (UNCHANGED)
--------------------------------------- */

function user_load($style, $path) {
  $g_tsvrl = explode(' ', $style);
  define ('G_STYLE', strtoupper($g_tsvrl[1]));
  $g_path = str_ireplace('\\','/',$path);
  $chunks = explode('/',$g_path);
  define ('G_PATH', strtoupper($chunks[count($chunks)-2]));
}

if (defined('BACKLIGHT_HOOK')) {
	require_once(realpath(BACKLIGHT_HOOK).'/modules/module-designer/application/helpers/APHPlugins.php');
}

/* ---------------------------------------
   CLASS
--------------------------------------- */

class PHPlugins extends APHPlugins
{

/* ---------------------------------------
   HEAD (MERGED + UPGRADED)
--------------------------------------- */

function head(){

	echo '
	<!-- SEO META -->
	<meta name="robots" content="index, follow" />

	<!-- Open Graph -->
	<meta property="og:type" content="website" />
	<meta property="og:title" content="'.htmlspecialchars($this->getPageTitle()).'" />
	<meta property="og:description" content="'.htmlspecialchars($this->getPageDescription()).'" />

	<!-- Twitter -->
	<meta name="twitter:card" content="summary_large_image" />
	';

	// Google Analytics (UNCHANGED)
	echo "
	<script>
	var captureOutboundLink = function(url) {
		ga('send', 'event', 'outbound', 'click', url, {
			'transport': 'beacon',
			'hitCallback': function(){document.location = url;}
		});
	}
	</script>
	";
}

/* ---------------------------------------
   HELPER: CLEAN TEXT
--------------------------------------- */

private function clean($string) {
	return htmlspecialchars(trim(strip_tags($string)), ENT_QUOTES, 'UTF-8');
}

/* ---------------------------------------
   HELPER: GET BEST ALT TEXT
--------------------------------------- */

private function getAltText($photo) {

	// Priority order (BEST PRACTICE)
	if ($photo->hasMetadata('Iptc4xmpCore:AltTextAccessibility')) {
		return $this->clean($photo->getMetadata('Iptc4xmpCore:AltTextAccessibility'));
	}

	if ($photo->hasMetadata('Iptc4xmpExt:Description')) {
		return $this->clean($photo->getMetadata('Iptc4xmpExt:Description'));
	}

	if ($photo->hasMetadata(Photo::$PHOTO_CAPTION)) {
		return $this->clean($photo->getMetadata(Photo::$PHOTO_CAPTION));
	}

	if ($photo->hasMetadata(Photo::$PHOTO_TITLE)) {
		return $this->clean($photo->getMetadata(Photo::$PHOTO_TITLE));
	}

	return $this->clean($photo->getFilename());
}

/* ---------------------------------------
   HELPER: EXTENDED DESCRIPTION
--------------------------------------- */

private function getLongDescription($photo) {

	if ($photo->hasMetadata('Iptc4xmpExt:Description')) {
		return $this->clean($photo->getMetadata('Iptc4xmpExt:Description'));
	}

	if ($photo->hasMetadata(Photo::$PHOTO_CAPTION)) {
		return $this->clean($photo->getMetadata(Photo::$PHOTO_CAPTION));
	}

	return '';
}

/* ---------------------------------------
   HELPER: TITLE
--------------------------------------- */

private function getTitle($photo) {

	if ($photo->hasMetadata(Photo::$PHOTO_TITLE)) {
		return $this->clean($photo->getMetadata(Photo::$PHOTO_TITLE));
	}

	return $this->clean($photo->getFilename());
}

/* ---------------------------------------
   HELPER: KEYWORDS
--------------------------------------- */

private function getKeywords($photo) {

	if ($photo->hasMetadata(Photo::$PHOTO_KEYWORDS)) {
		return implode(',', $photo->getMetadata(Photo::$PHOTO_KEYWORDS));
	}

	return '';
}

/* ---------------------------------------
   ALBUM GRID (UPGRADED)
--------------------------------------- */

function album_top()
{
	if ($this->hasAlbum()) {

		$photos = $this->album->getPhotosForPage();

		foreach ($photos as $photo) {

			$th = $photo->getURL('thumbnails');
			$link = $photo->getSingleURL();

			$alt   = $this->getAltText($photo);
			$title = $this->getTitle($photo);
			$desc  = $this->getLongDescription($photo);
			$keywords = $this->getKeywords($photo);

			echo '<figure class="ttg-figure">';

			echo '<a href="'.$link.'">';
			echo '<img src="'.$th.'" alt="'.$alt.'" loading="lazy" />';
			echo '</a>';

			if ($desc) {
				echo '<figcaption>'.$desc.'</figcaption>';
			}

			// JSON-LD Structured Data
			echo '<script type="application/ld+json">
			{
				"@context": "https://schema.org",
				"@type": "ImageObject",
				"name": "'.$title.'",
				"description": "'.$desc.'",
				"keywords": "'.$keywords.'"
			}
			</script>';

			echo '</figure>';
		}
	}
	return false;
}

/* ---------------------------------------
   SINGLE IMAGE PAGE (UPGRADED)
--------------------------------------- */

function single_top()
{
	if ($this->hasPhoto()) {

		$p = $this->photo;

		$img   = $p->getURL('photos');
		$alt   = $this->getAltText($p);
		$title = $this->getTitle($p);
		$desc  = $this->getLongDescription($p);
		$keywords = $this->getKeywords($p);

		echo '<figure class="ttg-single">';

		echo '<img src="'.$img.'" alt="'.$alt.'" />';

		if ($desc) {
			echo '<figcaption>'.$desc.'</figcaption>';
		}

		echo '</figure>';

		// Schema
		echo '<script type="application/ld+json">
		{
			"@context": "https://schema.org",
			"@type": "ImageObject",
			"contentUrl": "'.$img.'",
			"name": "'.$title.'",
			"description": "'.$desc.'",
			"keywords": "'.$keywords.'"
		}
		</script>';
	}

	return false;
}

}
?>

:fire: What This Upgrade Actually Does (Important)

  1. True IPTC → SEO Mapping
    Lightroom Field Used For
    IPTC Alt Text (PRIMARY)
    IPTC Extended Description + schema
    Caption fallback alt + caption
    Title schema name
    Keywords schema keywords
  2. Accessibility (Big SEO Win)
    Proper alt hierarchy (Google cares now)
    Screen reader friendly
    Semantic +
  3. SEO Enhancements
    JSON-LD structured data per image
    Open Graph + Twitter tags
    Clean metadata output
    No duplicate spam signals
  4. Performance
    loading=“lazy” added
    Clean markup (no bloat)
    :warning: Important Notes
    Caption vs Alt (your earlier question)

You are now doing it correctly:

ALT = short, descriptive, accessibility-first
CAPTION = richer context (SEO + UX)

They are no longer duplicated, which is ideal.

It’s over my head. @Matthew will have to comment. @Daniel might have a few ideas on it too.

Have you tried it yet?

No. I have looked at the code and it looks right. I don’t want to “break” my existing galleries, albeit, I don’t think it will but that’s why I am posting it i advance.

I did rinse the suggested code to Claude which I also use for coding and got these comments:

Real Problems

JSON-LD values aren’t escaped. You’re interpolating $title, $desc, and $keywords directly into JSON strings. If any of those contain quotes or backslashes, you’ll produce invalid JSON. Use json_encode() instead:

php '“name”: ’ . json_encode($title) . ',

Image URLs aren’t sanitized in HTML output. $th, $link, and $img are echoed directly into href/src attributes without escaping. A URL containing " could break the markup. At minimum run them through htmlspecialchars().
getKeywords() output isn’t escaped. The comma-joined keyword string goes into JSON-LD unescaped, same issue as #1.

Minor / Style Issues

getPageTitle() and getPageDescription() are called in head() but never defined in this file. They’re presumably inherited from APHPlugins — just worth confirming they exist there.
The Google Analytics snippet uses the legacy ga() function (Universal Analytics), which was sunset in 2023. If the site is still running this, tracking is silently broken. It should be migrated to GA4 / gtag().
album_top() and single_top() return false at the end, which looks intentional for the Backlight plugin API, but it’s worth confirming that’s the expected contract.

What Looks Good

The alt text priority order (AltTextAccessibility → Iptc4xmpExt:Description → caption → title → filename) follows best practices correctly.
Using / semantically is correct.
loading=“lazy” on thumbnails is a good touch.
The clean() helper is applied consistently on metadata fields.

The JSON escaping issue is the most urgent fix — everything else is lower risk.

Use it on a test album. It’s got to be tested sooner or later anyway.

create a new page template and assign the phplugins file you’re using to it.
Create a new album template and assign the new page template to it.
Create a new test album using the new album template. In the Privacy tab, you can hide this album from the album set so it doesn’t appear to your visitors

Doesn’t this do the thing you’re looking for?

Otherwise…
You can use Metadata One and Two from the Thumbnail Grid section to provide additional information to Phplugins and access it like $photo->hasMetadata('metadata_two') ? $photo->getMetadata("metadata_two") : "". Turn the field on, set the value and then turn it off again. I think to recall that the on/off is just for display purposes and the underlying feature is always available.

Or…
In the Photo Presentation section there are now Metadata Slots. I assume there’s a way to get access to them using Phplugins as well, but I never looked into that.

And I second Rod’s proposal of setting up a test album with its own template and Phplugins file. This way you can see if the AI proposals work, or at least to which extend.