Looking to join a great team — open to 186/482 visa sponsorship in Australia. Learn more
How to Build a WordPress Plugin from Scratch: Part 3 – Connecting Templates

How to Build a WordPress Plugin from Scratch: Part 3 – Connecting Templates

Contents

  1. Why templates matter in a plugin
  2. Step 1 — Create OZPG_Templates
  3. Step 2 — Add three template files
  4. Step 3 — Register the class in the main plugin
  5. Where theme overrides are loaded from
  6. Quick testing checklist
  7. FAQ

Why templates matter in a plugin

By default, WordPress will try to display your custom post types with generic theme files like archive.php and single.php. That’s rarely what you want for a gallery. A better pattern is to hook into the template loading process and route requests for your gallery screens to purpose‑built files inside your plugin — while still allowing developers to override them in a child/parent theme.

  • Archive/index — a landing page for your gallery (list of albums).
  • Album — shows one album and its photos.
  • Photo — a focused view for a single image.

This keeps your plugin self‑contained and predictable, and it plays nicely with theme overrides.

Step 1 — Create includes/class-ozpg-templates.php

Create a new file at includes/class-ozpg-templates.php with the following code:

<?php
defined('ABSPATH') || exit;

/**
 * Front-end templates and routing for OZ Photo Gallery.
 * Theme override:
 *  copy any file from /templates/ into your (child) theme at:
 *      /oz-photo-gallery/{same-filename}.php
 */
class OZPG_Templates {
    public static function init() {
        add_filter('template_include', [self::class, 'route_templates'], 20);
    }

    public static function add_rewrite() {
        $slug = oz_get_gallery_slug();

        // /{gallery-slug}/  -> gallery index
        add_rewrite_tag('%ozpg_root%', '1');
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/?$', 'index.php?ozpg_root=1', 'top');

        // Lazy flush trigger (main plugin handles the actual flush)
        if (!get_option('ozpg_flush_rewrite')) {
            update_option('ozpg_flush_rewrite', 1);
        }
    }

    public static function register_query_vars($vars) {
        $vars[] = 'ozpg_root';
        return $vars;
    }

    public static function route_templates($template) {
        // /oz-gallery/ - archive for gallery_album
        if (is_post_type_archive('gallery_album')) {
            return self::locate('gallery-index.php');
        }

        if (is_singular('gallery_album')) {
            return self::locate('gallery-album.php');
        }

        if (is_singular('gallery_photo')) {
            return self::locate('gallery-photo.php');
        }

        return $template;
    }

    // Locate template: child theme -> parent theme -> plugin fallback
    public static function locate($file) {
        $paths = [
            trailingslashit(get_stylesheet_directory()) . 'oz-photo-gallery/' . $file,
            trailingslashit(get_template_directory())   . 'oz-photo-gallery/' . $file,
            plugin_dir_path(__DIR__) . 'templates/' . $file,
        ];
        foreach ($paths as $p) {
            if (file_exists($p)) return $p;
        }
        return end($paths);
    }
}

Note: The class includes optional rewrite helpers for a clean root index (e.g. /oz-gallery/). If you already manage rewrite flush elsewhere in your plugin (recommended), you’re set.

Step 2 — Add three template files

Inside your plugin’s templates/ folder, remove the old placeholder and create these files:

  1. gallery-index.php — the gallery landing/archives
  2. gallery-album.php — a single album
  3. gallery-photo.php — a single photo

Minimal placeholders to confirm routing

templates/gallery-index.php

<?php
// Galery Index Template
defined('ABSPATH') || exit;

get_header();
?>
<main class="oz-allery-index">
<nav class="breadcrumbs">
  <a href="<?php echo esc_url(home_url()); ?>">Home</a> &raquo;
  <span>Gallery</span>
</nav>

  <h1>Photo Albums</h1>
  <div class="album-grid">
    <?php
    $albums = new WP_Query([
      'post_type' => 'gallery_album',
      'posts_per_page' => -1,
      'meta_key' => '_order',
      'orderby' => 'meta_value_num',
      'order' => 'ASC'
    ]);
    while ($albums->have_posts()) : $albums->the_post();
      $cover = get_the_post_thumbnail_url(get_the_ID(), 'medium');
      $permalink = get_permalink();
    ?>
      <a href="<?php echo esc_url($permalink); ?>" class="album-item">
        <div class="album-cover" style="background-image: url('<?php echo esc_url($cover); ?>')"></div>
        <div class="album-title"><?php the_title(); ?></div>
        <?php if (has_excerpt()): ?>
          <div class="album-excerpt">
            <?php the_excerpt(); ?>
          </div>
        <?php endif; ?>
      </a>
    <?php endwhile; wp_reset_postdata(); ?>
  </div>
</main>
<?php get_footer(); ?>

templates/gallery-album.php

<?php
// Galery Album Template
defined('ABSPATH') || exit;

get_header();
?>
<main class="oz-gallery-album">
<nav class="breadcrumbs">
  <a href="<?php echo esc_url(home_url()); ?>">Home</a> &raquo;
  <a href="<?php echo esc_url(home_url('/' . oz_get_gallery_slug())); ?>/">Gallery</a> &raquo;
  <span><?php the_title(); ?></span>
</nav>

  <h1><?php the_title(); ?></h1>

  <?php if (has_excerpt()): ?>
    <div class="album-excerpt">
      <?php the_excerpt(); ?>
    </div>
  <?php endif; ?>

  <div class="photo-grid">
    <?php
    $album_id = get_the_ID();
    $album_slug = get_post_field('post_name', $album_id);
    $photos = get_posts([
      'post_type' => 'gallery_photo',
      'meta_query' => [[
        'key' => '_album_id',
        'value' => $album_id,
        'compare' => '='
      ]],
      'posts_per_page' => -1,
      'orderby' => 'meta_value_num',
      'meta_key' => '_order',
      'order' => 'ASC'
    ]);

    if ($photos):
      foreach ($photos as $photo):
        $thumb = get_the_post_thumbnail_url($photo->ID, 'medium');
        $photo_slug = $photo->post_name;
        $photo_url = home_url('/' . oz_get_gallery_slug() . '/' . $album_slug . '/' . $photo_slug . '/');
    ?>
        <a href="<?php echo esc_url($photo_url); ?>" class="photo-thumb">
          <img src="<?php echo esc_url($thumb); ?>" alt="<?php echo esc_attr($photo->post_title); ?>">
        </a>
        
      <?php endforeach;
    endif;
    ?>
  </div>

  <div class="album-description">
    <?php the_content(); ?>
  </div>
</main>
<?php get_footer(); ?>

templates/gallery-photo.php

<?php
// Galery Detail Photo Template (no cache)
defined('ABSPATH') || exit;

get_header();

global $oz_seo_title, $oz_seo_description;
$oz_seo_title = get_the_title();
$oz_seo_description = has_excerpt() ? get_the_excerpt() : wp_trim_words(strip_tags(get_the_content()), 30);
?>
<main class="oz-gallery-photo">
<?php
$curr_id = (int) get_the_ID();

/** 1) Defining albume: meta _album_id -> parent */
$album_id = (int) get_post_meta($curr_id, '_album_id', true);
if (!$album_id) {
  $album_id = (int) wp_get_post_parent_id($curr_id);
}
$album_slug = $album_id ? (string) get_post_field('post_name', $album_id) : '';

/** 2) Gathering ID all album photos */
$photo_ids = get_posts([
  'post_type'      => 'gallery_photo',
  'fields'         => 'ids',
  'posts_per_page' => -1,
  'orderby'        => 'meta_value_num',
  'meta_key'       => '_order',
  'order'          => 'ASC',
  'meta_query'     => [[
    'key'     => '_album_id',
    'value'   => $album_id,
    'compare' => '='
  ]],
  'no_found_rows'  => true,
  'update_post_meta_cache'  => false,
  'update_post_term_cache'  => false,
]);

// Fallback if _album_id is empty
if (empty($photo_ids)) {
  $photo_ids = get_posts([
    'post_type'      => 'gallery_photo',
    'fields'         => 'ids',
    'posts_per_page' => -1,
    'orderby'        => 'meta_value_num',
    'meta_key'       => '_order',
    'order'          => 'ASC',
    'post_parent'    => $album_id,
    'no_found_rows'  => true,
    'update_post_meta_cache'  => false,
    'update_post_term_cache'  => false,
  ]);
}

$photo_ids = array_map('intval', (array) $photo_ids);

/** 3) Prev/Next */
$prev = $next = null;
if ($photo_ids) {
  $index = array_search($curr_id, $photo_ids); // без строгого сравнения, т.к. уже int
  if ($index !== false) {
    $prev_id = $photo_ids[$index - 1] ?? null;
    $next_id = $photo_ids[$index + 1] ?? null;

    if ($prev_id) {
      $prev = (object)[
        'ID'         => $prev_id,
        'post_title' => get_the_title($prev_id),
        'url'        => get_permalink($prev_id),
      ];
    }
    if ($next_id) {
      $next = (object)[
        'ID'         => $next_id,
        'post_title' => get_the_title($next_id),
        'url'        => get_permalink($next_id),
      ];
    }
  }
}
?>

<nav class="breadcrumbs">
  <a href="<?php echo esc_url(home_url()); ?>">Home</a> &raquo;
  <a href="<?php echo esc_url(home_url('/' . oz_get_gallery_slug())); ?>">Gallery</a> &raquo;
  <?php if ($album_id): ?>
    <a href="<?php echo esc_url(get_permalink($album_id)); ?>">
      <?php echo esc_html(get_the_title($album_id)); ?>
    </a> &raquo;
  <?php endif; ?>
  <span><?php echo esc_html(get_the_title()); ?></span>
</nav>

<h1><?php echo esc_html(get_the_title()); ?></h1>

<div class="photo-full">
  <?php if (has_post_thumbnail($curr_id)): ?>
    <img src="<?php echo esc_url(get_the_post_thumbnail_url($curr_id, 'large')); ?>" alt="<?php echo esc_attr(get_the_title()); ?>">
  <?php else: ?>
    <div class="no-photo">No photo available</div>
  <?php endif; ?>
</div>

<div><?php echo apply_filters('the_content', get_the_content()); ?></div>

<nav class="photo-nav" role="navigation">
  <?php if ($prev): ?>
    <a class="nav-thumb prev" href="<?php echo esc_url($prev->url); ?>">
      <?php $pthumb = get_the_post_thumbnail_url($prev->ID, 'thumbnail'); ?>
      <?php if ($pthumb): ?>
        <img src="<?php echo esc_url($pthumb); ?>" alt="<?php echo esc_attr($prev->post_title); ?>">
      <?php else: ?>
        <span>&larr; <?php echo esc_html($prev->post_title); ?></span>
      <?php endif; ?>
    </a>
  <?php else: ?>
    <span></span>
  <?php endif; ?>

  <?php if ($next): ?>
    <a class="nav-thumb next" href="<?php echo esc_url($next->url); ?>">
      <?php $nthumb = get_the_post_thumbnail_url($next->ID, 'thumbnail'); ?>
      <?php if ($nthumb): ?>
        <img src="<?php echo esc_url($nthumb); ?>" alt="<?php echo esc_attr($next->post_title); ?>">
      <?php else: ?>
        <span><?php echo esc_html($next->post_title); ?> &rarr;</span>
      <?php endif; ?>
    </a>
  <?php else: ?>
    <span></span>
  <?php endif; ?>
</nav>

<p class="back-to-album">
  <?php if ($album_id): ?>
    <a href="<?php echo esc_url(get_permalink($album_id)); ?>">&larr; Back to album</a>
  <?php endif; ?>
</p>

</main>
<?php
get_footer();
wp_reset_postdata();

Step 3 — Register the class in the main plugin file

Open oz-photo-gallery.php and load the class. Then initialise it so template routing kicks in:

<?php
// ...
require_once OZPG_PATH . 'includes/class-ozpg-templates.php';

if (class_exists('OZPG_Templates')) {
    OZPG_Templates::init();
}

Heads up: If you’re adding new rewrite rules, visit Settings → Permalinks once (or trigger your lazy flush) so WordPress regenerates rules.

Where theme overrides are loaded from

To customise the markup without touching the plugin, copy any template from /templates/ into your theme. The loader checks these paths in order:

PriorityPathExample
1 (highest)/wp-content/themes/<your-child-theme>/oz-photo-gallery/{filename}.php…/themes/child/oz-photo-gallery/gallery-photo.php
2/wp-content/themes/<your-parent-theme>/oz-photo-gallery/{filename}.php…/themes/parent/oz-photo-gallery/gallery-photo.php
3 (fallback)/wp-content/plugins/oz-photo-gallery/templates/{filename}.php…/plugins/oz-photo-gallery/templates/gallery-photo.php

This approach is predictable for theme developers and keeps your plugin updates safe.

Quick testing checklist

  • Visit /oz-gallery/ (or your chosen slug) — you should see the Gallery Index placeholder.
  • Open a gallery_album single — your Album template should render.
  • Open a gallery_photo single — your Photo template should render with the featured image.
  • Copy gallery-photo.php into your child theme at /oz-photo-gallery/, tweak the heading, and refresh — confirm the override wins.
  • If you added new rewrite rules, resave permalinks once.

FAQ

Can I still use block content inside these templates?
Yes. If your albums or photos use the block editor, the_content() will render blocks as usual. You can also enqueue block styles conditionally per template.
Do I have to implement the root index route?
No. It’s optional sugar so /oz-gallery/ can be a first‑class landing page. The archive view for gallery_album will still work via WordPress’ native archive URL.
Is this compatible with most themes?
Yes. The loader returns a real file path, so themes don’t need special support. If a theme wants to customise markup, it can override per the documented paths.