How to Build a WordPress Plugin from Scratch: Part 3 – Connecting Templates
Contents
- Why templates matter in a plugin
- Step 1 — Create
OZPG_Templates - Step 2 — Add three template files
- Step 3 — Register the class in the main plugin
- Where theme overrides are loaded from
- Quick testing checklist
- 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:
gallery-index.php— the gallery landing/archivesgallery-album.php— a single albumgallery-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> »
<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> »
<a href="<?php echo esc_url(home_url('/' . oz_get_gallery_slug())); ?>/">Gallery</a> »
<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> »
<a href="<?php echo esc_url(home_url('/' . oz_get_gallery_slug())); ?>">Gallery</a> »
<?php if ($album_id): ?>
<a href="<?php echo esc_url(get_permalink($album_id)); ?>">
<?php echo esc_html(get_the_title($album_id)); ?>
</a> »
<?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>← <?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); ?> →</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)); ?>">← 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:
| Priority | Path | Example |
|---|---|---|
| 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_albumsingle — your Album template should render. - Open a
gallery_photosingle — your Photo template should render with the featured image. - Copy
gallery-photo.phpinto 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.