Production XML Sitemap Plugin for WordPress

Learn how to create a production-ready WordPress XML sitemap plugin with sitemap index support, split sitemap files, file caching, robots.txt integration, noindex exclusions, XSL styling, and manual regeneration from the admin dashboard.


Production XML Sitemap Plugin for WordPress

Why a Custom WordPress Sitemap Plugin Matters

  • A sitemap helps search engines discover your website content faster.
  • WordPress already includes a default sitemap system, but many site owners need more control.
  • A custom sitemap plugin gives you better flexibility.
  • You can decide which post types appear.
  • You can exclude legal pages, thank-you pages, private content, or posts marked as noindex.
  • You can also generate cached XML files for better performance.
  • The plugin below is designed for production use.

It creates a sitemap index at:

https://yourdomain.com/sitemap.xml

It also creates child sitemap files like:

https://yourdomain.com/sitemap-post-1.xml
https://yourdomain.com/sitemap-page-1.xml

What This Plugin Does

This WordPress plugin includes several important sitemap features.

  • It generates a sitemap index.
  • It splits URLs into separate sitemap files.
  • It stores cached sitemap files inside the uploads folder.
  • It adds the sitemap automatically to robots.txt.
  • It supports excluded post IDs.
  • It supports excluded slugs.
  • It respects Yoast SEO noindex settings.
  • It respects Rank Math noindex settings.
  • It includes an XSL stylesheet for human-friendly sitemap viewing.
  • It adds a tools page in the WordPress dashboard.
  • It allows manual sitemap regeneration.
  • It schedules sitemap regeneration after content changes.

Plugin File Location

Create this folder:

wp-content/plugins/gp-production-sitemap/

Then create this file:

wp-content/plugins/gp-production-sitemap/gp-production-sitemap.php

Paste the code below inside that file.


Full Plugin Code

<?php
/**
 * File: wp-content/plugins/gp-production-sitemap/gp-production-sitemap.php
 * Plugin Name: GP Production Sitemap
 * Description: Production-ready XML sitemap generator with sitemap index, split files, file cache, robots.txt integration, exclusions, noindex support, XSL, and manual regeneration.
 * Version: 2.0.0
 * Author: HelpZone
 */

if (!defined('ABSPATH')) {
    exit;
}

final class GP_Production_Sitemap
{
    private static ?self $instance = null;

    private const VERSION = '2.0.0';

    private const QUERY_INDEX = 'gp_sitemap_index';
    private const QUERY_XSL = 'gp_sitemap_xsl';
    private const QUERY_TYPE = 'gp_sitemap_type';
    private const QUERY_PAGE = 'gp_sitemap_page';

    private const OPTION_FLUSH = 'gp_psm_flush_rewrite';
    private const OPTION_POST_TYPES = 'gp_psm_post_types';
    private const OPTION_EXCLUDED_IDS = 'gp_psm_excluded_ids';
    private const OPTION_EXCLUDED_SLUGS = 'gp_psm_excluded_slugs';
    private const OPTION_CACHE_ENABLED = 'gp_psm_cache_enabled';
    private const OPTION_URLS_PER_FILE = 'gp_psm_urls_per_file';
    private const OPTION_LAST_GENERATED = 'gp_psm_last_generated';
    private const OPTION_LAST_TOTAL_URLS = 'gp_psm_last_total_urls';

    private const CRON_HOOK = 'gp_psm_regenerate_event';
    private const LOCK_TRANSIENT = 'gp_psm_generation_lock';
    private const THROTTLE_TRANSIENT = 'gp_psm_regen_scheduled';

    private string $cache_dir = '';
    private string $index_file = '';

    public static function instance(): self
    {
        return self::$instance ??= new self();
    }

    private function __construct()
    {
        $upload_dir = wp_upload_dir();
        $base_dir = trailingslashit($upload_dir['basedir']) . 'gp-sitemaps/';

        $this->cache_dir = $base_dir;
        $this->index_file = $this->cache_dir . 'sitemap.xml';

        add_action('init', [$this, 'init']);
        add_filter('query_vars', [$this, 'register_query_vars']);
        add_action('template_redirect', [$this, 'serve_requests'], 0);
        add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2);

        add_action('save_post', [$this, 'schedule_regeneration'], 10, 3);
        add_action('deleted_post', [$this, 'schedule_regeneration_on_delete'], 10, 1);
        add_action('transition_post_status', [$this, 'schedule_regeneration_on_status_transition'], 10, 3);
        add_action(self::CRON_HOOK, [$this, 'cron_regenerate']);

        add_action('admin_menu', [$this, 'add_admin_menu']);
        add_action('admin_init', [$this, 'handle_admin_actions']);
        add_action('admin_notices', [$this, 'admin_notices']);
    }

    public function init(): void
    {
        add_rewrite_rule('^sitemap\.xml$', 'index.php?' . self::QUERY_INDEX . '=1', 'top');
        add_rewrite_rule('^sitemap\.xsl$', 'index.php?' . self::QUERY_XSL . '=1', 'top');
        add_rewrite_rule(
            '^sitemap-([a-z0-9_-]+)-([0-9]+)\.xml$',
            'index.php?' . self::QUERY_TYPE . '=$matches[1]&' . self::QUERY_PAGE . '=$matches[2]',
            'top'
        );

        if (get_option(self::OPTION_FLUSH)) {
            flush_rewrite_rules(false);
            delete_option(self::OPTION_FLUSH);
        }
    }

    public function register_query_vars(array $vars): array
    {
        $vars[] = self::QUERY_INDEX;
        $vars[] = self::QUERY_XSL;
        $vars[] = self::QUERY_TYPE;
        $vars[] = self::QUERY_PAGE;
        return $vars;
    }

    public function serve_requests(): void
    {
        if (get_query_var(self::QUERY_XSL)) {
            $this->serve_xsl();
            exit;
        }

        if (get_query_var(self::QUERY_INDEX)) {
            $this->serve_index();
            exit;
        }

        $type = get_query_var(self::QUERY_TYPE);
        $page = (int) get_query_var(self::QUERY_PAGE);

        if ($type && $page > 0) {
            $this->serve_child_sitemap(sanitize_key((string) $type), $page);
            exit;
        }
    }

    public function filter_robots_txt(string $output, bool $public): string
    {
        if (!$public) {
            return $output;
        }

        $line = 'Sitemap: ' . home_url('/sitemap.xml');

        if (strpos($output, $line) !== false) {
            return $output;
        }

        return trim($output) . "\n" . $line . "\n";
    }

    public function schedule_regeneration(int $post_id, WP_Post $post, bool $update): void
    {
        if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
            return;
        }

        $this->schedule_regeneration_event();
    }

    public function schedule_regeneration_on_delete(int $post_id): void
    {
        if ($post_id <= 0) {
            return;
        }

        $this->schedule_regeneration_event();
    }

    public function schedule_regeneration_on_status_transition(string $new_status, string $old_status, WP_Post $post): void
    {
        if ($new_status === $old_status) {
            return;
        }

        $this->schedule_regeneration_event();
    }

    private function schedule_regeneration_event(): void
    {
        if (!$this->is_cache_enabled()) {
            return;
        }

        if (!get_transient(self::THROTTLE_TRANSIENT)) {
            wp_schedule_single_event(time() + 20, self::CRON_HOOK);
            set_transient(self::THROTTLE_TRANSIENT, true, 60);
        }
    }

    public function cron_regenerate(): void
    {
        delete_transient(self::THROTTLE_TRANSIENT);

        if (!$this->is_cache_enabled()) {
            return;
        }

        if ($this->is_generation_locked()) {
            return;
        }

        $this->acquire_generation_lock();

        try {
            $this->build_cache();
        } finally {
            $this->release_generation_lock();
        }
    }

    private function is_cache_enabled(): bool
    {
        return (bool) get_option(self::OPTION_CACHE_ENABLED, true);
    }

    private function urls_per_file(): int
    {
        $value = (int) get_option(self::OPTION_URLS_PER_FILE, 2000);
        $value = max(100, min(50000, $value));

        return (int) apply_filters('gp_psm_urls_per_file', $value);
    }

    private function get_post_types(): array
    {
        $saved = get_option(self::OPTION_POST_TYPES, ['post', 'page']);

        if (!is_array($saved) || empty($saved)) {
            $saved = ['post', 'page'];
        }

        $saved = array_map('sanitize_key', $saved);

        $valid = [];
        foreach ($saved as $type) {
            if (!post_type_exists($type) || $type === 'attachment') {
                continue;
            }

            $obj = get_post_type_object($type);
            if (!$obj || !$obj->public) {
                continue;
            }

            $valid[] = $type;
        }

        if (empty($valid)) {
            $valid = ['post', 'page'];
        }

        return array_values(array_unique($valid));
    }

    private function get_excluded_ids(): array
    {
        $saved = get_option(self::OPTION_EXCLUDED_IDS, []);

        if (is_string($saved)) {
            $saved = preg_split('/[\s,]+/', $saved);
        }

        if (!is_array($saved)) {
            return [];
        }

        $ids = array_map('intval', $saved);
        $ids = array_filter($ids, static fn ($id) => $id > 0);

        return array_values(array_unique($ids));
    }

    private function get_excluded_slugs(): array
    {
        $saved = get_option(self::OPTION_EXCLUDED_SLUGS, [
            'privacy-policy',
            'legal-notice',
            'disclaimer',
            'cookies-policy',
            'terms-and-conditions',
            'thank-you',
            'thank-you-for-donation',
        ]);

        if (is_string($saved)) {
            $saved = preg_split('/\r\n|\r|\n|,/', $saved);
        }

        if (!is_array($saved)) {
            return [];
        }

        $slugs = array_map('sanitize_title', $saved);
        $slugs = array_filter($slugs);

        return array_values(array_unique($slugs));
    }

    private function ensure_cache_dir(): bool
    {
        if (is_dir($this->cache_dir)) {
            return is_writable($this->cache_dir);
        }

        wp_mkdir_p($this->cache_dir);

        return is_dir($this->cache_dir) && is_writable($this->cache_dir);
    }

    private function child_file_path(string $post_type, int $page): string
    {
        return $this->cache_dir . 'sitemap-' . $post_type . '-' . $page . '.xml';
    }

    private function child_url(string $post_type, int $page): string
    {
        return home_url('/sitemap-' . rawurlencode($post_type) . '-' . $page . '.xml');
    }

    private function xsl_url(): string
    {
        return home_url('/sitemap.xsl');
    }

    private function is_generation_locked(): bool
    {
        return (bool) get_transient(self::LOCK_TRANSIENT);
    }

    private function acquire_generation_lock(): void
    {
        set_transient(self::LOCK_TRANSIENT, 1, 180);
    }

    private function release_generation_lock(): void
    {
        delete_transient(self::LOCK_TRANSIENT);
    }

    private function should_include_post(int $post_id, string $post_type): bool
    {
        if ($post_id <= 0) {
            return false;
        }

        $post = get_post($post_id);
        if (!$post instanceof WP_Post) {
            return false;
        }

        if ($post->post_status !== 'publish') {
            return false;
        }

        if (post_password_required($post_id)) {
            return false;
        }

        if (in_array($post_id, $this->get_excluded_ids(), true)) {
            return false;
        }

        $slug = (string) $post->post_name;
        if ($slug !== '' && in_array(sanitize_title($slug), $this->get_excluded_slugs(), true)) {
            return false;
        }

        if ((int) get_option('page_on_front') === $post_id) {
            return false;
        }

        $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
        if ($yoast_noindex === '1' || $yoast_noindex === 'noindex') {
            return false;
        }

        $rank_math_robots = get_post_meta($post_id, 'rank_math_robots', true);
        if (is_array($rank_math_robots) && in_array('noindex', $rank_math_robots, true)) {
            return false;
        }

        $custom_exclude = get_post_meta($post_id, '_gp_sitemap_exclude', true);
        if ((string) $custom_exclude === '1') {
            return false;
        }

        return (bool) apply_filters('gp_psm_should_include_post', true, $post_id, $post_type, $post);
    }

    private function count_urls_for_post_type(string $post_type): int
    {
        global $wpdb;

        $ids = $wpdb->get_col(
            $wpdb->prepare(
                "
                SELECT ID
                FROM {$wpdb->posts}
                WHERE post_status = 'publish'
                  AND post_type = %s
                ",
                $post_type
            )
        );

        if (empty($ids)) {
            return 0;
        }

        $count = 0;
        foreach ($ids as $id) {
            if ($this->should_include_post((int) $id, $post_type)) {
                $count++;
            }
        }

        return $count;
    }

    private function get_child_lastmod_for_post_type(string $post_type): string
    {
        global $wpdb;

        $modified = $wpdb->get_var(
            $wpdb->prepare(
                "
                SELECT MAX(post_modified_gmt)
                FROM {$wpdb->posts}
                WHERE post_status = 'publish'
                  AND post_type = %s
                ",
                $post_type
            )
        );

        if (!$modified) {
            $modified = current_time('mysql', true);
        }

        return (string) mysql2date('c', $modified, false);
    }

    private function iterate_post_rows(string $post_type, int $offset, int $limit): Generator
    {
        global $wpdb;

        $batch_size = 500;
        $front_page_id = (int) get_option('page_on_front', 0);

        $seen_valid = 0;
        $yielded = 0;
        $cursor_modified = '9999-12-31 23:59:59';
        $cursor_id = PHP_INT_MAX;

        while ($yielded < $limit) {
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    "
                    SELECT ID, post_modified_gmt
                    FROM {$wpdb->posts}
                    WHERE post_status = 'publish'
                      AND post_type = %s
                      AND ID != %d
                      AND (
                            post_modified_gmt < %s
                         OR (post_modified_gmt = %s AND ID < %d)
                      )
                    ORDER BY post_modified_gmt DESC, ID DESC
                    LIMIT %d
                    ",
                    $post_type,
                    $front_page_id,
                    $cursor_modified,
                    $cursor_modified,
                    $cursor_id,
                    $batch_size
                )
            );

            if (empty($rows)) {
                break;
            }

            foreach ($rows as $row) {
                $post_id = (int) $row->ID;

                if (!$this->should_include_post($post_id, $post_type)) {
                    continue;
                }

                if ($seen_valid < $offset) {
                    $seen_valid++;
                    continue;
                }

                yield $row;
                $yielded++;

                if ($yielded >= $limit) {
                    break;
                }
            }

            $last = end($rows);
            $cursor_modified = (string) $last->post_modified_gmt;
            $cursor_id = (int) $last->ID;

            if (count($rows) < $batch_size) {
                break;
            }

            @set_time_limit(20);
        }
    }

    private function send_xml_headers(int $last_modified): void
    {
        while (ob_get_level()) {
            ob_end_clean();
        }

        status_header(200);
        header('Content-Type: application/xml; charset=UTF-8');
        header('X-Robots-Tag: noindex, follow', true);
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $last_modified) . ' GMT');
        header('Cache-Control: public, max-age=3600');
        header('X-Content-Type-Options: nosniff', true);
    }

    private function maybe_send_304(int $last_modified): void
    {
        $if_modified_since = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';

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

        $since = strtotime($if_modified_since);
        if ($since === false) {
            return;
        }

        if ($last_modified > 0 && $since >= $last_modified) {
            status_header(304);
            header('Cache-Control: public, max-age=3600');
            header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $last_modified) . ' GMT');
            exit;
        }
    }

    private function serve_xsl(): void
    {
        while (ob_get_level()) {
            ob_end_clean();
        }

        status_header(200);
        header('Content-Type: text/xsl; charset=UTF-8');
        header('Cache-Control: public, max-age=3600');
        header('X-Content-Type-Options: nosniff', true);

        echo '<?xml version="1.0" encoding="UTF-8"?>';
        ?>
<xsl:stylesheet version="1.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:sitemap="http://www.sitemaps.org/schemas/sitemap/0.9">
    <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
    <xsl:template match="/">
        <html>
            <head>
                <title>XML Sitemap</title>
                <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
                <style>
                    body { font-family: Arial, sans-serif; color: #222; margin: 40px; }
                    h1 { margin-bottom: 8px; }
                    p { color: #666; }
                    table { border-collapse: collapse; width: 100%; margin-top: 20px; }
                    th, td { border: 1px solid #ddd; padding: 10px; text-align: left; vertical-align: top; }
                    th { background: #f7f7f7; }
                    a { color: #0073aa; text-decoration: none; word-break: break-all; }
                    a:hover { text-decoration: underline; }
                </style>
            </head>
            <body>
                <xsl:choose>
                    <xsl:when test="sitemap:sitemapindex">
                        <h1>XML Sitemap Index</h1>
                        <p>This sitemap index contains child sitemaps.</p>
                        <table>
                            <thead>
                                <tr>
                                    <th>URL</th>
                                    <th>Last Modified</th>
                                </tr>
                            </thead>
                            <tbody>
                                <xsl:for-each select="sitemap:sitemapindex/sitemap:sitemap">
                                    <tr>
                                        <td><a href="{sitemap:loc}"><xsl:value-of select="sitemap:loc"/></a></td>
                                        <td><xsl:value-of select="sitemap:lastmod"/></td>
                                    </tr>
                                </xsl:for-each>
                            </tbody>
                        </table>
                    </xsl:when>
                    <xsl:otherwise>
                        <h1>XML Sitemap</h1>
                        <p>This sitemap contains individual URLs.</p>
                        <table>
                            <thead>
                                <tr>
                                    <th>URL</th>
                                    <th>Last Modified</th>
                                </tr>
                            </thead>
                            <tbody>
                                <xsl:for-each select="sitemap:urlset/sitemap:url">
                                    <tr>
                                        <td><a href="{sitemap:loc}"><xsl:value-of select="sitemap:loc"/></a></td>
                                        <td><xsl:value-of select="sitemap:lastmod"/></td>
                                    </tr>
                                </xsl:for-each>
                            </tbody>
                        </table>
                    </xsl:otherwise>
                </xsl:choose>
            </body>
        </html>
    </xsl:template>
</xsl:stylesheet>
<?php
        exit;
    }

    private function serve_index(): void
    {
        if ($this->is_cache_enabled() && file_exists($this->index_file)) {
            $mtime = (int) @filemtime($this->index_file);
            $this->maybe_send_304($mtime);
            $this->send_xml_headers($mtime);
            readfile($this->index_file);
            return;
        }

        if ($this->is_generation_locked() && file_exists($this->index_file)) {
            $mtime = (int) @filemtime($this->index_file);
            $this->maybe_send_304($mtime);
            $this->send_xml_headers($mtime);
            readfile($this->index_file);
            return;
        }

        $this->acquire_generation_lock();

        try {
            if ($this->is_cache_enabled() && $this->ensure_cache_dir()) {
                $this->build_cache();
                if (file_exists($this->index_file)) {
                    $mtime = (int) @filemtime($this->index_file);
                    $this->maybe_send_304($mtime);
                    $this->send_xml_headers($mtime);
                    readfile($this->index_file);
                    return;
                }
            }

            $this->send_xml_headers(time());
            $this->render_index_to_output();
        } finally {
            $this->release_generation_lock();
        }
    }

    private function serve_child_sitemap(string $post_type, int $page): void
    {
        if (!in_array($post_type, $this->get_post_types(), true)) {
            status_header(404);
            exit;
        }

        $file = $this->child_file_path($post_type, $page);

        if ($this->is_cache_enabled() && file_exists($file)) {
            $mtime = (int) @filemtime($file);
            $this->maybe_send_304($mtime);
            $this->send_xml_headers($mtime);
            readfile($file);
            return;
        }

        if ($this->is_generation_locked() && file_exists($file)) {
            $mtime = (int) @filemtime($file);
            $this->maybe_send_304($mtime);
            $this->send_xml_headers($mtime);
            readfile($file);
            return;
        }

        $this->acquire_generation_lock();

        try {
            if ($this->is_cache_enabled() && $this->ensure_cache_dir()) {
                $this->build_cache();
                if (file_exists($file)) {
                    $mtime = (int) @filemtime($file);
                    $this->maybe_send_304($mtime);
                    $this->send_xml_headers($mtime);
                    readfile($file);
                    return;
                }
            }

            $exists = $this->count_urls_for_post_type($post_type);
            $max_pages = (int) ceil($exists / $this->urls_per_file());

            if ($page < 1 || $page > max(1, $max_pages)) {
                status_header(404);
                exit;
            }

            $this->send_xml_headers(time());
            $this->render_child_to_output($post_type, $page);
        } finally {
            $this->release_generation_lock();
        }
    }

    private function build_cache(): void
    {
        if (!$this->ensure_cache_dir()) {
            return;
        }

        $this->cleanup_old_cache_files();

        $total_urls = 1;
        $index_entries = [];

        foreach ($this->get_post_types() as $post_type) {
            $count = $this->count_urls_for_post_type($post_type);

            if ($count <= 0) {
                continue;
            }

            $pages = (int) ceil($count / $this->urls_per_file());
            $lastmod = $this->get_child_lastmod_for_post_type($post_type);

            for ($page = 1; $page <= $pages; $page++) {
                $this->write_child_file($post_type, $page);
                $index_entries[] = [
                    'loc'     => $this->child_url($post_type, $page),
                    'lastmod' => $lastmod,
                ];
            }

            $total_urls += $count;
        }

        $this->write_index_file($index_entries);

        update_option(self::OPTION_LAST_GENERATED, time());
        update_option(self::OPTION_LAST_TOTAL_URLS, $total_urls);
    }

    private function cleanup_old_cache_files(): void
    {
        if (!is_dir($this->cache_dir)) {
            return;
        }

        $files = glob($this->cache_dir . 'sitemap-*.xml');
        if (!$files) {
            return;
        }

        foreach ($files as $file) {
            if (is_file($file)) {
                @unlink($file);
            }
        }

        if (is_file($this->index_file)) {
            @unlink($this->index_file);
        }
    }

    private function write_index_file(array $entries): void
    {
        $tmp = $this->index_file . '.tmp';
        $writer = new XMLWriter();

        if (!$writer->openURI($tmp)) {
            return;
        }

        $writer->startDocument('1.0', 'UTF-8');
        $writer->writePI('xml-stylesheet', 'type="text/xsl" href="' . esc_url($this->xsl_url()) . '"');
        $writer->startElement('sitemapindex');
        $writer->writeAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');

        foreach ($entries as $entry) {
            $writer->startElement('sitemap');
            $writer->writeElement('loc', esc_url($entry['loc']));
            $writer->writeElement('lastmod', esc_html($entry['lastmod']));
            $writer->endElement();
        }

        $writer->endElement();
        $writer->endDocument();
        $writer->flush();

        @rename($tmp, $this->index_file);
    }

    private function write_child_file(string $post_type, int $page): void
    {
        $file = $this->child_file_path($post_type, $page);
        $tmp = $file . '.tmp';

        $writer = new XMLWriter();
        if (!$writer->openURI($tmp)) {
            return;
        }

        $this->write_child_document($writer, $post_type, $page);

        @rename($tmp, $file);
    }

    private function render_index_to_output(): void
    {
        $writer = new XMLWriter();
        $writer->openURI('php://output');
        $writer->startDocument('1.0', 'UTF-8');
        $writer->writePI('xml-stylesheet', 'type="text/xsl" href="' . esc_url($this->xsl_url()) . '"');
        $writer->startElement('sitemapindex');
        $writer->writeAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');

        foreach ($this->get_post_types() as $post_type) {
            $count = $this->count_urls_for_post_type($post_type);
            if ($count <= 0) {
                continue;
            }

            $pages = (int) ceil($count / $this->urls_per_file());
            $lastmod = $this->get_child_lastmod_for_post_type($post_type);

            for ($page = 1; $page <= $pages; $page++) {
                $writer->startElement('sitemap');
                $writer->writeElement('loc', esc_url($this->child_url($post_type, $page)));
                $writer->writeElement('lastmod', esc_html($lastmod));
                $writer->endElement();
            }
        }

        $writer->endElement();
        $writer->endDocument();
        $writer->flush();
    }

    private function render_child_to_output(string $post_type, int $page): void
    {
        $writer = new XMLWriter();
        $writer->openURI('php://output');
        $this->write_child_document($writer, $post_type, $page);
    }

    private function write_child_document(XMLWriter $writer, string $post_type, int $page): void
    {
        $limit = $this->urls_per_file();
        $offset = ($page - 1) * $limit;

        $writer->startDocument('1.0', 'UTF-8');
        $writer->writePI('xml-stylesheet', 'type="text/xsl" href="' . esc_url($this->xsl_url()) . '"');
        $writer->startElement('urlset');
        $writer->writeAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');

        if ($post_type === 'page' && $page === 1) {
            $home_mod = get_lastpostmodified('GMT') ?: current_time('mysql', true);
            $this->write_url_node(
                $writer,
                home_url('/'),
                mysql2date('c', $home_mod, false)
            );
        }

        foreach ($this->iterate_post_rows($post_type, $offset, $limit) as $row) {
            $post_id = (int) $row->ID;
            $loc = get_permalink($post_id);

            if (!$loc || $loc === home_url('/')) {
                continue;
            }

            $lastmod = mysql2date('c', (string) $row->post_modified_gmt, false);
            $this->write_url_node($writer, $loc, $lastmod);
        }

        $writer->endElement();
        $writer->endDocument();
        $writer->flush();
    }

    private function write_url_node(XMLWriter $writer, string $loc, string $lastmod): void
    {
        $writer->startElement('url');
        $writer->writeElement('loc', esc_url($loc));
        $writer->writeElement('lastmod', esc_html($lastmod));
        $writer->endElement();
    }

    public function add_admin_menu(): void
    {
        add_management_page(
            'GP Production Sitemap',
            'GP Sitemap',
            'manage_options',
            'gp-production-sitemap',
            [$this, 'admin_page']
        );
    }

    public function admin_page(): void
    {
        $post_types = $this->get_post_types();
        $excluded_ids = implode(', ', $this->get_excluded_ids());
        $excluded_slugs = implode("\n", $this->get_excluded_slugs());
        $cache_enabled = $this->is_cache_enabled();
        $urls_per_file = $this->urls_per_file();
        $last_generated = (int) get_option(self::OPTION_LAST_GENERATED, 0);
        $total_urls = (int) get_option(self::OPTION_LAST_TOTAL_URLS, 0);
        $all_public_types = get_post_types(['public' => true], 'objects');

        ?>
        <div class="wrap">
            <h1>GP Production Sitemap</h1>

            <div class="card" style="max-width: 980px; padding: 20px; margin-top: 20px;">
                <h2>Status</h2>
                <table class="widefat striped" style="margin-top: 15px;">
                    <tbody>
                        <tr>
                            <td style="width: 220px;"><strong>Sitemap Index</strong></td>
                            <td><a href="<?php echo esc_url(home_url('/sitemap.xml')); ?>" target="_blank"><?php echo esc_html(home_url('/sitemap.xml')); ?></a></td>
                        </tr>
                        <tr>
                            <td><strong>XSL</strong></td>
                            <td><a href="<?php echo esc_url(home_url('/sitemap.xsl')); ?>" target="_blank"><?php echo esc_html(home_url('/sitemap.xsl')); ?></a></td>
                        </tr>
                        <tr>
                            <td><strong>Cache Directory</strong></td>
                            <td><code><?php echo esc_html($this->cache_dir); ?></code></td>
                        </tr>
                        <tr>
                            <td><strong>Cache</strong></td>
                            <td><?php echo $cache_enabled ? 'Enabled' : 'Disabled'; ?></td>
                        </tr>
                        <tr>
                            <td><strong>URLs / File</strong></td>
                            <td><?php echo esc_html((string) $urls_per_file); ?></td>
                        </tr>
                        <tr>
                            <td><strong>Total URLs (last build)</strong></td>
                            <td><?php echo esc_html(number_format_i18n($total_urls)); ?></td>
                        </tr>
                        <tr>
                            <td><strong>Last Generated</strong></td>
                            <td>
                                <?php if ($last_generated > 0): ?>
                                    <?php echo esc_html(wp_date('Y-m-d H:i:s', $last_generated)); ?>
                                <?php else: ?>
                                    Never
                                <?php endif; ?>
                            </td>
                        </tr>
                    </tbody>
                </table>

                <form method="post" action="" style="margin-top: 20px;">
                    <?php wp_nonce_field('gp_psm_regenerate', 'gp_psm_regenerate_nonce'); ?>
                    <input type="hidden" name="gp_psm_action" value="regenerate">
                    <button type="submit" class="button button-primary">Regenerate Now</button>
                </form>
            </div>

            <div class="card" style="max-width: 980px; padding: 20px; margin-top: 20px;">
                <h2>Settings</h2>

                <form method="post" action="">
                    <?php wp_nonce_field('gp_psm_save_settings', 'gp_psm_settings_nonce'); ?>
                    <input type="hidden" name="gp_psm_action" value="save_settings">

                    <table class="form-table" role="presentation">
                        <tr>
                            <th scope="row">Enable file cache</th>
                            <td>
                                <label>
                                    <input type="checkbox" name="cache_enabled" value="1" <?php checked($cache_enabled); ?>>
                                    Store generated sitemap files in uploads/gp-sitemaps
                                </label>
                            </td>
                        </tr>

                        <tr>
                            <th scope="row">URLs per sitemap file</th>
                            <td>
                                <input type="number" min="100" max="50000" name="urls_per_file" value="<?php echo esc_attr((string) $urls_per_file); ?>" class="small-text">
                            </td>
                        </tr>

                        <tr>
                            <th scope="row">Included post types</th>
                            <td>
                                <?php foreach ($all_public_types as $type_key => $type_obj): ?>
                                    <?php if ($type_key === 'attachment') continue; ?>
                                    <label style="display:inline-block; min-width:180px; margin:0 14px 8px 0;">
                                        <input
                                            type="checkbox"
                                            name="post_types[]"
                                            value="<?php echo esc_attr($type_key); ?>"
                                            <?php checked(in_array($type_key, $post_types, true)); ?>
                                        >
                                        <?php echo esc_html($type_obj->labels->singular_name . ' (' . $type_key . ')'); ?>
                                    </label>
                                <?php endforeach; ?>
                            </td>
                        </tr>

                        <tr>
                            <th scope="row">Excluded IDs</th>
                            <td>
                                <input type="text" name="excluded_ids" value="<?php echo esc_attr($excluded_ids); ?>" class="regular-text code">
                                <p class="description">Example: 12, 44, 118</p>
                            </td>
                        </tr>

                        <tr>
                            <th scope="row">Excluded slugs</th>
                            <td>
                                <textarea name="excluded_slugs" rows="10" cols="60" class="large-text code"><?php echo esc_textarea($excluded_slugs); ?></textarea>
                                <p class="description">One slug per line.</p>
                            </td>
                        </tr>
                    </table>

                    <p class="submit">
                        <button type="submit" class="button button-secondary">Save Settings</button>
                    </p>
                </form>
            </div>
        </div>
        <?php
    }

    public function handle_admin_actions(): void
    {
        if (!current_user_can('manage_options')) {
            return;
        }

        if (!isset($_POST['gp_psm_action'])) {
            return;
        }

        $action = sanitize_text_field(wp_unslash($_POST['gp_psm_action']));

        if ($action === 'save_settings') {
            if (!check_admin_referer('gp_psm_save_settings', 'gp_psm_settings_nonce')) {
                return;
            }

            $cache_enabled = isset($_POST['cache_enabled']);
            update_option(self::OPTION_CACHE_ENABLED, $cache_enabled);

            $urls_per_file = isset($_POST['urls_per_file']) ? (int) wp_unslash($_POST['urls_per_file']) : 2000;
            $urls_per_file = max(100, min(50000, $urls_per_file));
            update_option(self::OPTION_URLS_PER_FILE, $urls_per_file);

            $post_types = isset($_POST['post_types']) && is_array($_POST['post_types'])
                ? array_map('sanitize_key', wp_unslash($_POST['post_types']))
                : ['post', 'page'];

            $valid_types = [];
            foreach ($post_types as $type) {
                if (!post_type_exists($type) || $type === 'attachment') {
                    continue;
                }

                $obj = get_post_type_object($type);
                if (!$obj || !$obj->public) {
                    continue;
                }

                $valid_types[] = $type;
            }

            if (empty($valid_types)) {
                $valid_types = ['post', 'page'];
            }

            update_option(self::OPTION_POST_TYPES, array_values(array_unique($valid_types)));

            $excluded_ids_raw = isset($_POST['excluded_ids']) ? wp_unslash($_POST['excluded_ids']) : '';
            $excluded_ids = preg_split('/[\s,]+/', (string) $excluded_ids_raw);
            $excluded_ids = array_map('intval', $excluded_ids);
            $excluded_ids = array_values(array_filter(array_unique($excluded_ids), static fn ($id) => $id > 0));
            update_option(self::OPTION_EXCLUDED_IDS, $excluded_ids);

            $excluded_slugs_raw = isset($_POST['excluded_slugs']) ? wp_unslash($_POST['excluded_slugs']) : '';
            $excluded_slugs = preg_split('/\r\n|\r|\n|,/', (string) $excluded_slugs_raw);
            $excluded_slugs = array_map('sanitize_title', $excluded_slugs);
            $excluded_slugs = array_values(array_filter(array_unique($excluded_slugs)));
            update_option(self::OPTION_EXCLUDED_SLUGS, $excluded_slugs);

            $this->schedule_regeneration_event();
            set_transient('gp_psm_notice', 'Settings saved.', 30);

            wp_safe_redirect(admin_url('tools.php?page=gp-production-sitemap'));
            exit;
        }

        if ($action === 'regenerate') {
            if (!check_admin_referer('gp_psm_regenerate', 'gp_psm_regenerate_nonce')) {
                return;
            }

            $this->acquire_generation_lock();

            try {
                $this->build_cache();
            } finally {
                $this->release_generation_lock();
            }

            set_transient('gp_psm_notice', 'Sitemap regenerated.', 30);

            wp_safe_redirect(admin_url('tools.php?page=gp-production-sitemap'));
            exit;
        }
    }

    public function admin_notices(): void
    {
        $notice = get_transient('gp_psm_notice');
        if (!$notice) {
            return;
        }

        printf(
            '<div class="notice notice-success is-dismissible"><p>%s</p></div>',
            esc_html($notice)
        );

        delete_transient('gp_psm_notice');
    }
}

add_action('plugins_loaded', [GP_Production_Sitemap::class, 'instance']);

register_activation_hook(__FILE__, static function (): void {
    add_option(GP_Production_Sitemap::OPTION_FLUSH, true);
    add_option(GP_Production_Sitemap::OPTION_CACHE_ENABLED, true);
    add_option(GP_Production_Sitemap::OPTION_POST_TYPES, ['post', 'page']);
    add_option(GP_Production_Sitemap::OPTION_EXCLUDED_IDS, []);
    add_option(GP_Production_Sitemap::OPTION_EXCLUDED_SLUGS, [
        'privacy-policy',
        'legal-notice',
        'disclaimer',
        'cookies-policy',
        'terms-and-conditions',
        'thank-you',
        'thank-you-for-donation',
    ]);
    add_option(GP_Production_Sitemap::OPTION_URLS_PER_FILE, 2000);
});

register_deactivation_hook(__FILE__, static function (): void {
    flush_rewrite_rules(false);
    wp_clear_scheduled_hook(GP_Production_Sitemap::CRON_HOOK);
    delete_transient(GP_Production_Sitemap::LOCK_TRANSIENT);
    delete_transient(GP_Production_Sitemap::THROTTLE_TRANSIENT);
});

Important Note About the Code

For WordPress publishing, you can paste the full PHP code into the article inside a code block. However, on a live WordPress site, never paste plugin PHP code into a post editor to run it. The code must be saved as a plugin file inside:

wp-content/plugins/gp-production-sitemap/gp-production-sitemap.php

Then activate it from:

WordPress Dashboard → Plugins

Main Features Explained

Sitemap Index Support

  • The plugin creates a main sitemap index.
  • This index points search engines to smaller sitemap files.
  • This is useful for large websites because one sitemap should not contain unlimited URLs.

Split Sitemap Files

  • The plugin supports split sitemap files by post type.
  • For example, posts and pages can have separate sitemap files.
  • This keeps the sitemap cleaner and easier to process.

File Cache System

The plugin stores generated sitemap files in:

wp-content/uploads/gp-sitemaps/

This improves performance because WordPress does not need to rebuild the sitemap on every request.

Robots.txt Integration

The plugin automatically adds this line to robots.txt:

Sitemap: https://yourdomain.com/sitemap.xml

This helps search engines find the sitemap faster.

Noindex Support

  • The plugin checks common SEO plugin settings.
  • It excludes posts marked as noindex in Yoast SEO.
  • It also excludes posts marked as noindex in Rank Math.
  • This is important because noindex pages should not appear in XML sitemaps.

Excluded IDs and Slugs

  • The admin settings page allows you to exclude specific content.
  • You can exclude posts by ID.
  • You can also exclude pages by slug.

Default excluded slugs include:

privacy-policy
legal-notice
disclaimer
cookies-policy
terms-and-conditions
thank-you
thank-you-for-donation

Admin Dashboard Page

The plugin adds a settings page under:

Tools → GP Sitemap

From there, you can view sitemap status, change settings, and regenerate the sitemap manually.


How to Install the Plugin

Step 1: Create the Plugin Folder

Go to your WordPress installation and open:

wp-content/plugins/

Create a new folder:

gp-production-sitemap

Step 2: Add the PHP File

Inside the folder, create:

gp-production-sitemap.php

Paste the full plugin code into this file.

Step 3: Activate the Plugin

Go to:

WordPress Dashboard → Plugins

Find:

GP Production Sitemap

Click:

Activate

Step 4: Check the Sitemap

Open this URL:

https://yourdomain.com/sitemap.xml

You should see the sitemap index.

Step 5: Configure Settings

Go to:

Tools → GP Sitemap

Choose post types, cache settings, excluded IDs, and excluded slugs.

Recommended Settings

For most WordPress blogs, use these settings:

Cache: Enabled
URLs per sitemap file: 2000
Included post types: post, page
Excluded slugs: privacy-policy, disclaimer, cookies-policy, terms-and-conditions

For large websites, increase URLs per file carefully.

A safe range is:

1000 to 5000 URLs per file

Testing Checklist

Before using the plugin on a live site, test these items.

Open:

/sitemap.xml

Check child sitemap URLs.

Open:

/sitemap-post-1.xml
/sitemap-page-1.xml

Check robots.txt:

/robots.txt
  • Confirm excluded pages are missing.
  • Confirm noindex posts are missing.
  • Regenerate the sitemap manually from the admin page.

Check that files appear in:

wp-content/uploads/gp-sitemaps/

Common Issues and Fixes

Sitemap Shows 404

Go to:

Settings → Permalinks

Click:

Save Changes

This refreshes WordPress rewrite rules.

Cache Folder Is Not Created

Check folder permissions for:

wp-content/uploads/

WordPress must be able to write files there.

Sitemap Does Not Update

Go to:

Tools → GP Sitemap

Click:

Regenerate Now

Legal Pages Still Appear

Add their slugs to the excluded slugs field.

Example:

privacy-policy
terms-and-conditions
cookie-policy
disclaimer

Security Notes

This plugin includes several good security practices.

  • It blocks direct file access.
  • It uses WordPress nonces for admin actions.
  • It checks user permissions before saving settings.
  • It sanitizes submitted values.
  • It uses escaping functions when printing admin output.
  • It adds XML headers correctly.
  • It sends noindex headers for sitemap files.

Performance Notes

The plugin is designed to avoid heavy sitemap generation on every request.

  • It uses file caching.
  • It throttles regeneration.
  • It uses a transient lock to prevent overlapping builds.
  • It processes posts in batches.

These features make it suitable for larger WordPress sites.

Production XML Sitemap Plugin for WordPress

Frequently Asked Questions

Does this replace the default WordPress sitemap?

Yes, this plugin creates its own sitemap at /sitemap.xml.

Can I use it with Yoast SEO?

Yes. It checks Yoast noindex meta values and excludes noindex posts.

Can I use it with Rank Math?

Yes. It checks Rank Math robots settings and excludes noindex content.

Where are sitemap files stored?

Cached sitemap files are stored in:

wp-content/uploads/gp-sitemaps/

Can I exclude pages from the sitemap?

Yes. You can exclude pages by ID or slug.

Does it add the sitemap to robots.txt?

Yes. It automatically adds the sitemap URL to robots.txt.

Can I regenerate the sitemap manually?

Yes. Go to:

Tools → GP Sitemap → Regenerate Now

Is this plugin safe for production?

It is designed for production use, but you should test it on staging first.

Does it support custom post types?

Yes. Public custom post types can be selected from the admin settings page.

Why does the sitemap include XSL?

The XSL file makes the XML sitemap easier for humans to read in the browser.


WordPress Site Owners

A clean XML sitemap helps search engines understand your site structure. This plugin gives you more control than the default WordPress sitemap. It is especially useful for blogs, news sites, documentation websites, and content-heavy WordPress projects. Use it carefully, test it first, and keep your excluded pages list updated.


⚠️ Disclaimer and Source Hygiene


This article is for educational and technical guidance only. Always test custom WordPress plugins on a staging website before using them on production. For business-critical websites, consult a professional WordPress developer or SEO specialist.

🔔 For more tutorials like this, consider subscribing to our blog.
📩 Do you have questions or suggestions? Leave a comment or contact us!
🏷️ Tags: WordPress sitemap plugin, XML sitemap WordPress, custom WordPress plugin, WordPress SEO, sitemap index, WordPress robots.txt, WordPress development, PHP plugin tutorial, Yoast noindex, Rank Math sitemap
📢 Hashtags: #WordPress, #WordPressPlugin, #XMLSitemap, #WordPressSEO, #SitemapPlugin, #PHPDevelopment, #WebDevelopment, #TechnicalSEO, #YoastSEO, #RankMath


📚 Sources and References

  • WordPress Plugin Developer Handbook
  • WordPress Rewrite API documentation
  • WordPress Cron API documentation
  • WordPress Options API documentation
  • Sitemaps XML protocol documentation
  • Yoast SEO noindex behavior
  • Rank Math robots meta behavior

Secondary Sources and Testimonials

This tutorial is based on practical WordPress development patterns, common SEO plugin behavior, and production sitemap requirements used by content-heavy websites.

Leave a Comment