What XenForo 2.3+ Should Build Next for “Web 3.0”-Ready SEO

Roiarthur

Active member
Search today rewards three things: structured understanding (schema/knowledge graphs), fast and stable UX (Core Web Vitals), and authentic conversations (forums, Q&A, expert authors). Below is a practical roadmap for XenForo 2.3+ to lead on all three.

1) Ship complete, context-aware structured data

Thread & post views


Output DiscussionForumPosting JSON-LD for discussion threads, including author, datePublished, commentCount, interactionStatistic (upvotes/likes), and canonical URL per page.

For Question forums, emit QAPage only when the thread is a real Q&A (and toggle it off if the thread is converted back to a general discussion). Map XenForo’s “Best answer” to acceptedAnswer. Validate against Google’s QAPage guidance.
Google for Developers

Profiles
On member_view, publish ProfilePage data with sameAs (links to the member’s verified sites), and optional knowsAbout (tags/areas of expertise surfaced from activity). This supports E-E-A-T signals in a standards-based way. (See Google’s structured-data guidelines hub for ongoing changes.)
Google for Developers

Resources (XFRM)
Provide schema presets per resource type: SoftwareApplication, HowTo, Book, etc., selectable in the ACP and inserted automatically into xfrm_resource_view. Reference Google’s rich-result docs (FAQ/HowTo/etc.) for valid properties.
Google for Developers

Global
Put site-wide Organization, Website (with sitelinks searchbox), and BreadcrumbList in PAGE_CONTAINER. Expose toggles per style/add-on and a live JSON-LD preview with Rich Results Test links.
Google for Developers


2) Make discovery & recrawl instantaneous

Sitemaps that reflect posts, not only threads

Generate a post-freshness sitemap where lastmod tracks the newest reply, improving recrawl of active discussions.

IndexNow in core (optional toggle)
When threads or posts are created/edited/soft-deleted, queue URL pings to IndexNow with retry/backoff and a dashboard. Provide key management in ACP.
IndexNow

Robots hygiene
Auto-noindex thin combinations (deep pagination + filters), enforce clean canonicals per page, and mark outbound UGC links rel="ugc" by default, with role-based exceptions for trusted members.
Google for Developers


3) Treat Core Web Vitals as first-class product metrics

INP-first engineering

Since INP replaced FID as a Core Web Vital, expose an ACP “Performance Budget” with guardrails: defer non-critical JS from add-ons, pre-allocate image heights (to prevent CLS), and limit long task handlers in thread lists and message editors. Provide developer hooks to register deferred scripts.
web.dev

Built-in asset hints
Automatic preconnect/preload for critical fonts, editor assets, and CDNs; lazy-load media with proper placeholders; responsive image srcsets for attachments and galleries.


4) Align with Search’s growing emphasis on forums & Q&A

Eligibility for forum-specific surfacing

Ensure public access to discussions (no interstitials on first view), strong internal linking (latest/related threads), and clear author attributions. These are consistent with Google’s trend of surfacing forum discussions and Q&A more prominently.
pageonepower.com

Search Console integration (optional add-on)
Pull GSC data via API into ACP panels filtered by thread type (Q&A vs discussion) to measure impressions/clicks for structured-result appearances and forum-specific surfaces. (Track changes with Google’s docs updates log.)
Google for Developers


5) Operationalize author trust (E-E-A-T)

Verified contributor profiles


Add ACP options for expert badges, affiliations, and identity verification. Expose a “Top answers” list per author (ItemList JSON-LD) and link from the profile. This rewards helpful contributors and clarifies expertise for search engines and users.

Reputation-aware link policy
Start with rel="ugc" forum-wide, but allow per-group rules to remove it automatically for consistently high-quality contributors, as Google permits.
Google for Developers


6) Internationalization, accessibility, and cleanliness

Auto-emit hreflang for multilingual communities and language-scoped forums.

Ensure pagination canonicals are correct and that infinite-scroll views still expose crawlable URLs.
Tighten ARIA roles and keyboard support in editor/thread lists; better accessibility helps INP and overall usability.


7) Developer & theming ergonomics

JSON-LD builder

A visual mapper in ACP that lets admins connect XenForo fields (thread title, best answer, like counts, tags) to schema fields, with type presets (Discussion/QAPage/HowTo/SoftwareApplication) and validation links.

Performance hooks
Documented events for: deferring add-on JS, batching intersection observers, image hydration strategies, and scheduling low-priority tasks after user input to protect INP.

Implementation checklist by template

PAGE_CONTAINER: Organization, Website, BreadcrumbList; preload/preconnect hints; lazy-loading defaults.

forum_view / thread_view: DiscussionForumPosting; switch to QAPage in Question forums; correct canonicals per page; UGC link policy.
Google for Developers

member_view: ProfilePage with sameAs, optional knowsAbout, and an ItemList of accepted answers.

xfrm_resource_view: Schema preset matching the resource type; optional FAQ blocks when appropriate.
Google for Developers

Why this matters

Faster recrawls and richer snippets from accurate schema + indexNow.

Better rankings stability by meeting today’s INP responsiveness bar.
web.dev

More visibility where search is steering users: real people discussing real problems in forums and Q&A.
pageonepower.com

If you’d like, I can deliver this as a practical bundle: JSON-LD macros for thread_view, member_view, and xfrm_resource_view, plus a lightweight IndexNow add-on with ACP controls and a minimal performance-budget overlay.
 
Upvote 4
What XenForo 2.3+ Should Build Next for Web-3.0-Ready SEO

Search in 2025 favors three things: structured understanding of pages, fast and stable interaction, and authentic human conversations. Forums are back in the spotlight—if they speak search engines’ language and load fast. Here’s a focused roadmap for XenForo 2.3+ to lead.


1) Ship complete, context-aware structured data (JSON-LD)

Discussion threads & posts


Emit Discussion Forum structured data on thread pages so Google can identify forum content and consider it for “Discussions & forums” and Perspectives surfaces. Include rich author objects, post counts, and interaction stats. This improves eligibility it’s not a guarantee.
Google for Developers

Question forums (true Q&A)

When a thread is a real question with answers, switch to QAPage. Map XenForo’s “Best answer” to acceptedAnswer, keep all other replies under suggestedAnswer, and fall back to discussion markup if the thread is converted back to general discussion. Validate with Google’s Rich Results Test.
Google for Developers

Member profiles

On member_view, publish Profile Page structured data: name, links (sameAs), and activity signals (for expertise context). This helps search better understand who is speaking in your community.
Google for Developers

Resource Manager

Provide schema presets per resource type (for example, HowTo, SoftwareApplication) and inject them into xfrm_resource_view only when properties are complete and compliant.
Google for Developers

Minimal example for a Question thread (server-side toggle to QAPage only in Question forums):

Code:
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "QAPage",
  "mainEntity": {
    "@type": "Question",
    "name": "How do I fix [problem] in XenForo?",
    "datePublished": "2025-10-10T12:34:00Z",
    "author": {"@type": "Person", "name": "Alice"},
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "Step 1… Step 2… with references.",
      "datePublished": "2025-10-11T09:12:00Z",
      "author": {"@type": "Person", "name": "Bob"},
      "upvoteCount": 23
    },
    "suggestedAnswer": [
      {"@type": "Answer", "text": "Alternative…", "author": {"@type": "Person", "name": "Carol"}}
    ]
  }
}
</script>

(Use Discussion Forum markup on standard discussions; don’t mix both on the same page.)
Google for Developers


2) Make discovery and re-crawl instantaneous

IndexNow: Add a core (or official add-on) implementation that pings participating engines on thread/post create, edit, move, and delete. Include a retry queue, key management, and a small ACP dashboard.
IndexNow

Sitemaps that reflect replies: Provide a “recent posts” sitemap whose lastmod follows the most recent reply, not only the thread creation time. (This increases the chance of fresh re-crawls on active topics.)

3) Treat Core Web Vitals as product requirements (INP-first)

Google replaced FID with INP (Interaction to Next Paint) on March 12, 2024. XenForo should expose an ACP “Performance Budget” and developer hooks to keep INP healthy: defer non-critical JS, pre-allocate media slots to prevent layout shift, batch long tasks, and cap heavy observers on long thread lists.
web.dev

Practical additions:

Async/deferred loading for add-on scripts with a platform-level queue.

Automatic loading="lazy" and responsive srcset for attachments and embedded media.

Hints (preconnect, preload) for critical editor/font assets.


4) Link hygiene for UGC (and rewarding trusted members)

Default outbound links in posts and signatures to rel="ugc". Allow role-based exceptions that remove ugc for consistently high-quality contributorsexactly as Google recommends for communities. Provide an ACP report tracking where exceptions are applied.
Google for Developers


5) Internationalization, accessibility, and canonical clarity

Emit clean canonicals per pagination page and make infinite scroll expose crawlable URLs.

Generate hreflang across multilingual forums and language-scoped sections to reduce duplication.

Tighten ARIA roles and keyboard support (good for users and often correlated with better INP).

Note: Google deprecated the Sitelinks Search Box visual feature in 2024; keep Organization/site name markup, but don’t rely on the old box.
Google for Developers

Search Engine Journal


6) Admin tools that make SEO operational


JSON-LD Builder (ACP): map XenForo fields (thread title, best answer, like counts, tags) to schema properties with presets for Discussion/QAPage/HowTo/SoftwareApplication; include a live preview and links to the Rich Results Test.
Google for Developers

Search Console panel (official add-on): pull impressions/clicks for threads and Q&A, segment by forum type, and highlight crawling/indexing issues affecting discussion and profile markup. (Monitor Google’s docs updates log to keep labels current.)
Google for Developers


7) Implementation checklist by template

PAGE_CONTAINER
: Organization/site name, breadcrumbs, critical asset hints.

forum_view / thread_view: Discussion Forum markup by default; switch to QAPage only in Question forums and when a best answer exists; strict canonicals per page; outbound link policy (rel="ugc").
Google for Developers

member_view: Profile Page markup with sameAs for verified links and optional expertise fields.
Google for Developers

xfrm_resource_view
: Correct type preset (for example, HowTo, SoftwareApplication) with required properties only when complete.
Google for Developers

Bottom line

Forums win in modern search when they’re understandable (clean schema for threads, questions, and people), discoverable (IndexNow + freshness-aware sitemaps), and delightfully responsive (INP-first). If XenForo bakes these into 2.3+ as core plus an official “SEO+” add-on, communities will be future-proofed for where search is heading.
 
Here’s a ready-to-install starter bundle you can drop into XenForo 2.3+. It’s split in two parts:

JSON-LD template macros you can include in thread_view, member_view, and (optionally) xfrm_resource_view.

A minimal IndexNow add-on skeleton (PHP) that pings on thread/post create/update/delete, with simple ACP options.

All code is in English as requested.


1) JSON-LD macros (single template)

Create a new template (admin → Appearance → Templates) named:

Code:
seo_jsonld_macros

Paste this content:

Code:
<xf:macro name="qapage" arg-thread="!" arg-bestAnswer="!" arg-suggestedAnswers="!">
    <xf:set var="$schema">
        {
            "@context": "https://schema.org",
            "@type": "QAPage",
            "mainEntity": {
                "@type": "Question",
                "name": "{$thread.title|escape('json')}",
                "text": "{$thread.first_post.messagePlain|stripTags|truncate(400)|escape('json')}",
                "author": {
                    "@type": "Person",
                    "name": "{$thread.User.username|escape('json')}"
                },
                "datePublished": "{$thread.post_date|date('c')}",
                "dateModified": "{$thread.last_post_date|date('c')}",
                "acceptedAnswer": {
                    "@type": "Answer",
                    "text": "{$bestAnswer.messagePlain|stripTags|escape('json')}",
                    "datePublished": "{$bestAnswer.post_date|date('c')}",
                    "author": {
                        "@type": "Person",
                        "name": "{$bestAnswer.User.username|escape('json')}"
                    },
                    "upvoteCount": {$bestAnswer.reaction_score|default(0)}
                },
                "suggestedAnswer": [
                    <xf:foreach loop="$suggestedAnswers" value="$ans" i="$i">
                        {
                            "@type": "Answer",
                            "text": "{$ans.messagePlain|stripTags|escape('json')}",
                            "datePublished": "{$ans.post_date|date('c')}",
                            "author": {
                                "@type": "Person",
                                "name": "{$ans.User.username|escape('json')}"
                            },
                            "upvoteCount": {$ans.reaction_score|default(0)}
                        }<xf:if is="$i < count($suggestedAnswers) - 1">,</xf:if>
                    </xf:foreach>
                ]
            }
        }
    </xf:set>

    <script type="application/ld+json"><xf:raw>{{ json($schema) }}</xf:raw></script>
</xf:macro>

<xf:macro name="discussion" arg-thread="!" arg-posts="!">
    <xf:set var="$interactionCount">{$thread.first_post.reaction_score|default(0) + $thread.reply_count|default(0)}</xf:set>
    <xf:set var="$schema">
        {
            "@context": "https://schema.org",
            "@type": "DiscussionForumPosting",
            "headline": "{$thread.title|escape('json')}",
            "articleBody": "{$thread.first_post.messagePlain|stripTags|escape('json')}",
            "datePublished": "{$thread.post_date|date('c')}",
            "dateModified": "{$thread.last_post_date|date('c')}",
            "author": { "@type": "Person", "name": "{$thread.User.username|escape('json')}" },
            "interactionStatistic": {
                "@type": "InteractionCounter",
                "interactionType": "https://schema.org/LikeAction",
                "userInteractionCount": {$interactionCount|default(0)}
            },
            "commentCount": {$thread.reply_count|default(0)},
            "comment": [
                <xf:foreach loop="$posts" value="$p" i="$i">
                    {
                        "@type": "Comment",
                        "text": "{$p.messagePlain|stripTags|escape('json')}",
                        "datePublished": "{$p.post_date|date('c')}",
                        "author": { "@type": "Person", "name": "{$p.User.username|escape('json')}" }
                    }<xf:if is="$i < count($posts) - 1">,</xf:if>
                </xf:foreach>
            ],
            "mainEntityOfPage": "{$thread.canonicalUrl()|escape('json')}"
        }
    </xf:set>

    <script type="application/ld+json"><xf:raw>{{ json($schema) }}</xf:raw></script>
</xf:macro>

<xf:macro name="profilePage" arg-user="!">
    <xf:set var="$sameAs">
        <xf:if is="$user.Profile.website">{"url":"{$user.Profile.website|escape('json')}"}<xf:elseif is="$user.Profile" />
        </xf:if>
    </xf:set>

    <xf:set var="$schema">
        {
            "@context": "https://schema.org",
            "@type": "ProfilePage",
            "name": "{$user.username|escape('json')}",
            "dateModified": "{$user.last_activity|date('c')}",
            "mainEntity": {
                "@type": "Person",
                "name": "{$user.username|escape('json')}",
                "image": "<xf:avatar user=\"$user\" size=\"m\" canonical=\"1\" />",
                <xf:if is="$user.Profile.about">"description": "{$user.Profile.about|stripTags|truncate(400)|escape('json')}",</xf:if>
                <xf:if is="$user.Profile.website">"sameAs": ["{$user.Profile.website|escape('json')}"],</xf:if>
                "interactionStatistic": {
                    "@type": "InteractionCounter",
                    "interactionType": "https://schema.org/CommentAction",
                    "userInteractionCount": {$user.message_count|default(0)}
                }
            }
        }
    </xf:set>

    <script type="application/ld+json"><xf:raw>{{ json($schema) }}</xf:raw></script>
</xf:macro>

<xf:macro name="howto" arg-resource="!" arg-steps="!">
    <xf:set var="$schema">
        {
            "@context":"https://schema.org",
            "@type":"HowTo",
            "name":"{$resource.title|escape('json')}",
            "description":"{$resource.tag_line|default($resource.description)|stripTags|truncate(300)|escape('json')}",
            "datePublished":"{$resource.create_date|date('c')}",
            "step":[
                <xf:foreach loop="$steps" value="$s" i="$i">
                { "@type":"HowToStep", "name":"{$s.title|escape('json')}", "text":"{$s.text|stripTags|escape('json')}" }<xf:if is="$i < count($steps) - 1">,</xf:if>
                </xf:foreach>
            ]
        }
    </xf:set>
    <script type="application/ld+json"><xf:raw>{{ json($schema) }}</xf:raw></script>
</xf:macro>

<xf:macro name="softwareApp" arg-resource="!">
    <xf:set var="$schema">
        {
            "@context":"https://schema.org",
            "@type":"SoftwareApplication",
            "name":"{$resource.title|escape('json')}",
            "applicationCategory":"Utilities",
            "operatingSystem":"Windows, macOS, Linux",
            "offers": { "@type":"Offer", "price":"0", "priceCurrency":"USD" },
            "aggregateRating": {
                "@type":"AggregateRating",
                "ratingValue":"{$resource.rating_avg|default(5)}",
                "reviewCount":"{$resource.rating_count|default(1)}"
            }
        }
    </xf:set>
    <script type="application/ld+json"><xf:raw>{{ json($schema) }}</xf:raw></script>
</xf:macro>

Where to include them

thread_view
(at the end of the template, once per page):

Code:
<xf:if is="$thread.type_id == 'question' && $thread.best_answer_id">

    <xf:macro template="seo_jsonld_macros" name="qapage"

        arg-thread="{$thread}"

        arg-bestAnswer="{$posts.{$thread.best_answer_id}}"

        arg-suggestedAnswers="{{ array_values($posts) }}" />

<xf:else />

    <xf:macro template="seo_jsonld_macros" name="discussion"

        arg-thread="{$thread}"

        arg-posts="{{ array_values($posts) }}" />

</xf:if>

member_view:

Code:
<xf:macro template="seo_jsonld_macros" name="profilePage" arg-user="{$user}" />

xfrm_resource_view (optional; pick one preset based on category/fields):

Code:
<xf:if is="$resource.custom_fields.howto_steps">
    <xf:macro template="seo_jsonld_macros" name="howto"
        arg-resource="{$resource}"
        arg-steps="{$resource.custom_fields.howto_steps}" />
<xf:elseif is="$resource.Resource.category_id == 1" />
    <xf:macro template="seo_jsonld_macros" name="softwareApp" arg-resource="{$resource}" />
</xf:if>

Notes

We deliberately don’t mix QAPage and DiscussionForumPosting on the same page.

messagePlain ensures we don’t inject HTML into JSON-LD.

json() + <xf:raw> prevents double-escaping inside <script type="application/ld+json">.


2) Minimal IndexNow add-on (skeleton)

Namespace: SD/IndexNow (change to your vendor prefix).

Create a new add-on from the Dev tools (or by hand). File structure:

Code:
src/addons/SD/IndexNow/addon.json
src/addons/SD/IndexNow/Setup.php
src/addons/SD/IndexNow/Listener/EntityHooks.php
src/addons/SD/IndexNow/Service/Pinger.php
src/addons/SD/IndexNow/Option/Options.php

addon.json

Code:
{
  "title": "IndexNow Pinger",
  "description": "Pings IndexNow on thread/post create/update/delete with retry.",
  "version_id": 1000070,
  "version_string": "1.0.0",
  "dev": "SD",
  "namespace": "SD\\IndexNow",
  "require": [],
  "icon": "fa-solid fa-sitemap"
}

Setup.php (register a simple option group)

Code:
<?php

namespace SD\IndexNow;

use XF\AddOn\AbstractSetup;
use XF\Db\Schema\Create;

class Setup extends AbstractSetup
{
    public function install(array $stepParams = [])
    {
        $this->schemaManager()->createTable('xf_sd_indexnow_queue', function (Create $table) {
            $table->addColumn('queue_id', 'int')->autoIncrement();
            $table->addColumn('url', 'varchar', 2083);
            $table->addColumn('action', 'varchar', 16)->setDefault('urlUpdated');
            $table->addColumn('attempts', 'int')->setDefault(0);
            $table->addColumn('next_attempt', 'int')->setDefault(0);
            $table->addPrimaryKey('queue_id');
            $table->addKey('next_attempt');
        });
    }

    public function upgrade(array $stepParams = [])
    {
    }

    public function uninstall(array $stepParams = [])
    {
        $this->schemaManager()->dropTable('xf_sd_indexnow_queue');
    }
}

Option/Options.php (ACP options provider)

Register two options in Admin → Setup → Options (create group sdIndexNow in the ACP, or ship _output/options if you prefer export). The handler:

Code:
<?php

namespace SD\IndexNow\Option;

class Options
{
    public static function isEnabled(\XF\App $app): bool
    {
        return (bool)$app->options()->sdIndexNow_enable;
    }

    public static function endpoint(\XF\App $app): string
    {
        return (string)$app->options()->sdIndexNow_endpoint ?: 'https://api.indexnow.org/indexnow';
    }

    public static function key(\XF\App $app): string
    {
        return (string)$app->options()->sdIndexNow_key;
    }

    public static function host(\XF\App $app): string
    {
        return (string)$app->options()->sdIndexNow_host ?: $app->options()->boardUrl;
    }
}

Create these ACP options:

sdIndexNow_enable
(checkbox)

sdIndexNow_key (text) — your IndexNow key

sdIndexNow_endpoint (text) — default https://api.indexnow.org/indexnow

sdIndexNow_host (text) — default to your board URL’s host

Service/Pinger.php

Code:
<?php

namespace SD\IndexNow\Service;

use SD\IndexNow\Option\Options;
use XF\App;
use XF\Mvc\Entity\Entity;

class Pinger
{
    protected App $app;

    public function __construct(App $app)
    {
        $this->app = $app;
    }

    public function queue(string $url, string $action = 'urlUpdated'): void
    {
        if (!Options::isEnabled($this->app)) { return; }

        $db = $this->app->db();
        $db->insert('xf_sd_indexnow_queue', [
            'url' => $url,
            'action' => $action,
            'attempts' => 0,
            'next_attempt' => time()
        ]);
    }

    public function processQueue(int $batch = 20): int
    {
        $rows = $this->app->db()->fetchAll("
            SELECT * FROM xf_sd_indexnow_queue
            WHERE next_attempt <= ?
            ORDER BY queue_id ASC
            LIMIT {$batch}
        ", [time()]);

        $sent = 0;
        foreach ($rows as $row)
        {
            if ($this->send([$row['url']], $row['action']))
            {
                $this->app->db()->delete('xf_sd_indexnow_queue', 'queue_id = ?', $row['queue_id']);
                $sent++;
            }
            else
            {
                // backoff: +5m * attempts
                $this->app->db()->update('xf_sd_indexnow_queue', [
                    'attempts' => $row['attempts'] + 1,
                    'next_attempt' => time() + max(300, ($row['attempts'] + 1) * 300)
                ], 'queue_id = ?', $row['queue_id']);
            }
        }

        return $sent;
    }

    public function send(array $urls, string $action = 'urlUpdated'): bool
    {
        $key = Options::key($this->app);
        $endpoint = Options::endpoint($this->app);
        $host = parse_url(Options::host($this->app), PHP_URL_HOST) ?: $_SERVER['HTTP_HOST'];

        if (!$key || !$endpoint || !$host || empty($urls)) { return false; }

        $payload = [
            'host' => $host,
            'key' => $key,
            'keyLocation' => "https://{$host}/{$key}.txt",
            $action => $urls
        ];

        try
        {
            $client = $this->app->http()->client();
            $response = $client->post($endpoint, [
                'headers' => ['Content-Type' => 'application/json'],
                'json' => $payload,
                'timeout' => 5
            ]);
            return ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300);
        }
        catch (\Throwable $e)
        {
            \XF::logError('[IndexNow] ' . $e->getMessage());
            return false;
        }
    }
}
 
Listener/EntityHooks.php
Code:
<?php

namespace SD\IndexNow\Listener;

use SD\IndexNow\Service\Pinger;
use XF\Mvc\Entity\Entity;

class EntityHooks
{
    // Called from code event listeners (configure in Admin > Development > Code event listeners)
    public static function entityPostSave(Entity $entity)
    {
        $app = \XF::app();
        $pinger = new Pinger($app);

        if ($entity instanceof \XF\Entity\Thread)
        {
            $pinger->queue($entity->getContentUrl(true), 'urlUpdated');
        }
        elseif ($entity instanceof \XF\Entity\Post)
        {
            // Post canonical may be page-specific; safest is the parent thread canonical
            $thread = $entity->Thread;
            if ($thread)
            {
                $pinger->queue($thread->getContentUrl(true), 'urlUpdated');
            }
        }
        elseif (class_exists('\XFRM\Entity\ResourceItem') && $entity instanceof \XFRM\Entity\ResourceItem)
        {
            $pinger->queue($entity->getContentUrl(true), 'urlUpdated');
        }
    }

    public static function entityPostDelete(Entity $entity)
    {
        $app = \XF::app();
        $pinger = new Pinger($app);

        if (method_exists($entity, 'getContentUrl'))
        {
            $pinger->queue($entity->getContentUrl(true), 'urlDeleted');
        }
    }
}

Register the listeners (Dev → Code event listeners):

Event: entity_post_save → SD\IndexNow\Listener\EntityHooks::entityPostSave

Event: entity_post_delete → SD\IndexNow\Listener\EntityHooks::entityPostDelete

Cron to process the queue (Dev → Cron entries):

Run every 5 minutes, callback:
Code:
SD\IndexNow:Cron::process/CODE]

Create the cron runner:

[CODE]src/addons/SD/IndexNow/Cron.php

Code:
<?php

namespace SD\IndexNow;

use SD\IndexNow\Service\Pinger;

class Cron
{
    public static function process()
    {
        $app = \XF::app();
        /** @var Pinger $pinger */
        $pinger = $app->service('SD\IndexNow:Service\Pinger');
        if (!$pinger) { $pinger = new Service\Pinger($app); }
        $pinger->processQueue(50);
    }
}

Quick integration checklist

Templates


Add seo_jsonld_macros template.

Include the macro calls in thread_view, member_view, and xfrm_resource_view as shown.

Clear caches and test with Google’s Rich Results Test.

IndexNow add-on

Install the add-on (zip the src/addons/SD/IndexNow folder + addon.json, or use Dev tools export).

In ACP → Options → IndexNow, enable it, set your key, ensure you host the https://yourhost/{key}.txt file as required by IndexNow.

Create the two listeners and the cron entry.

Post a test thread and check server logs / IndexNow dashboard.
 
Back
Top Bottom