How to Build a WordPress Plugin from Scratch: Part 2 – Custom Post Types and Admin UI
Welcome back to our WordPress plugin series. In Part 1, we built a clean plugin skeleton with separate modules for post types, asset enqueuing, and helpers. Now, let’s move on to adding functionality by registering custom post types (CPTs) for our photo gallery and enhancing the admin experience.
This part focuses on:
- Registering custom post types: Albums and Photos
- Setting up clean URLs like
/gallery/album-slug/photo-slug/ - Customising admin columns
- Adding order and album fields via meta boxes
- Making CPTs sortable in the admin area
- Creating a settings page for the plugin
Prefer watching over reading? You can check out the full tutorial on YouTube in the player below. Or if you’re more of an old-school type who likes a proper walkthrough in text — just scroll down.
Table of Contents
- Updating the Main Plugin File
- Updating class-ozpg-post-types.php
- Creating the class-ozpg-settings.php File
- Bonus: Dynamic Gallery Slug via Helper Function
Updating the Main Plugin File: oz-photo-gallery.php
In Part 1 of this series, our main plugin file only bootstrapped core modules. Now, with new features like clean permalinks and user-defined settings, we’ve made several key upgrades to improve how the plugin loads, behaves on activation, and responds to admin changes.
Key Changes Introduced
Included the Settings Module
We’ve added a new file:
require_once OZPG_PATH . 'includes/class-ozpg-settings.php';
This class registers a settings page in the WordPress admin under the “Oz Gallery” menu and provides options for:
- Root slug (e.g.,
/gallery/) - Cache duration
- Clearing the gallery’s internal cache
Improved Rewrite Rule Handling on Activation
Instead of just flushing rewrite rules, we now explicitly register post types first to ensure all rewrite rules are in place before flushing:
register_activation_hook(__FILE__, function () {
require_once OZPG_PATH . 'includes/helpers.php';
require_once OZPG_PATH . 'includes/class-ozpg-post-types.php';
if (class_exists('OZPG_Post_Types')) {
OZPG_Post_Types::register_post_types();
}
flush_rewrite_rules();
});
This prevents broken permalinks when the plugin is activated on a fresh site or when a custom slug is used.
Structured Initialisation
We now separately initialise each module only if its class exists. This allows future scalability — for example, if you want to lazy-load certain components:
add_action('plugins_loaded', function () {
if (class_exists('OZPG_Post_Types')) {
OZPG_Post_Types::init();
}
if (class_exists('OZPG_Settings')) {
OZPG_Settings::init();
}
if (class_exists('OZPG_Assets')) {
OZPG_Assets::init();
}
// Handle delayed rewrite flush
});
Deferred Rewrite Flush After Settings Change
To avoid rewriting permalinks on every page load, we now use an option flag:
if (get_option('ozpg_flush_rewrite')) {
flush_rewrite_rules();
delete_option('ozpg_flush_rewrite');
}
This flag is set by the settings page whenever the user changes the gallery slug. It gets cleaned up after rewrite rules are flushed once.
Why This Matters
This structure ensures that:
- Your permalinks are always reliable after activation or settings changes
- You avoid unnecessary rewrite flushes on every load
- The plugin remains modular and easy to extend
This approach aligns with best practices for WordPress plugin development and ensures a smooth user experience — especially important if you’re aiming to submit to the WordPress.org plugin repository or deploy in client environments.
2. Updating class-ozpg-post-types.php
Registering Custom Post Types: Albums & Photos
We register two post types to structure the photo gallery:
gallery_album: for grouping photosgallery_photo: individual images tied to an album
These types are public, support featured images and excerpts, and live under a shared menu called “Oz Gallery”. Albums serve as parents for gallery_photo posts.
public static function register_post_types() {
$gallery_slug = oz_get_gallery_slug();
register_post_type('gallery_album', [
'labels' => [
'name' => 'Albums',
'singular_name' => 'Album',
'add_new' => 'Add New Album',
'add_new_item' => 'Add New Album',
'edit_item' => 'Edit Album',
'new_item' => 'New Album',
'view_item' => 'View Album',
'search_items' => 'Search Albums',
'not_found' => 'No albums found',
'not_found_in_trash' => 'No albums found in Trash',
'all_items' => 'All Albums',
'menu_name' => 'Albums',
'name_admin_bar' => 'Album',
],
'public' => true,
'has_archive' => $gallery_slug,
'rewrite' => ['slug' => $gallery_slug, 'with_front' => false],
'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
'show_in_menu' => 'oz-gallery',
'menu_icon' => 'dashicons-format-gallery',
]);
//
add_rewrite_tag('%gallery_photo%', '([^&]+)');
//
register_post_type('gallery_photo', [
'labels' => [
'name' => 'Photos',
'singular_name' => 'Photo',
'add_new' => 'Upload New Photo',
'add_new_item' => 'Upload New Photo',
'edit_item' => 'Edit Photo',
'new_item' => 'New Photo',
'view_item' => 'View Photo',
'search_items' => 'Search Photos',
'not_found' => 'No photos found',
'not_found_in_trash' => 'No photos found in Trash',
'all_items' => 'All Photos',
'menu_name' => 'Photos',
'name_admin_bar' => 'Photo',
],
'public' => true,
'has_archive' => false,
'rewrite' => [
'slug' => $gallery_slug,
'with_front' => false,
'hierarchical' => true,
],
'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
'hierarchical' => true,
'show_in_menu' => 'oz-gallery',
]);
//
add_rewrite_rule(
'^gallery/([^/]+)/([^/]+)/?$',
'index.php?post_type=gallery_photo&name=$matches[2]',
'top'
);
}
This structure enables hierarchical URLs like /gallery/beach-holiday/sunset-over-bay.
Setting Up Pretty Permalinks
To make sure URLs reflect the album–photo relationship, we add a rewrite rule:
public static function filter_photo_permalink($post_link, $post) {
if ($post->post_type === 'gallery_photo') {
$parent = get_post($post->post_parent);
if ($parent && $parent->post_type === 'gallery_album') {
$album_slug = $parent->post_name;
$base_slug = oz_get_gallery_slug();
return home_url("/$base_slug/$album_slug/{$post->post_name}/");
}
}
return $post_link;
}
This ensures WordPress generates clean links like /gallery/mountains/snow-peak.
Custom Admin Menu
We add a top-level menu item called “Oz Gallery”, and redirect it to the albums list:
public static function add_gallery_menu() {
add_menu_page(
__('Oz Gallery', 'oz'),
__('Oz Gallery', 'oz'),
'edit_posts',
'oz-gallery',
[self::class, 'redirect_gallery_menu'],
'dashicons-format-gallery',
22
);
}
The default submenu item is removed to avoid confusion:
public static function redirect_gallery_menu() {
wp_safe_redirect(admin_url('edit.php?post_type=gallery_album'));
exit;
}
public static function remove_menu_duplicate() {
global $submenu;
if (isset($submenu['oz-gallery'][0]) && $submenu['oz-gallery'][0][2] === 'oz-gallery') {
unset($submenu['oz-gallery'][0]);
$submenu['oz-gallery'] = array_values($submenu['oz-gallery']);
}
}
Custom Admin Columns
We define sortable and useful columns for both Albums and Photos:
- Order
- Slug
- Thumbnail preview
//
public static function add_album_columns($columns) {
$columns['order'] = 'Order';
$columns['slug'] = 'Slug';
$columns['thumbnail'] = 'Thumbnail';
return $columns;
}
public static function add_photo_columns($columns) {
$columns['order'] = 'Order';
$columns['slug'] = 'Slug';
$columns['thumbnail'] = 'Thumbnail';
return $columns;
}
//
public static function render_album_column($column, $post_id) {
self::render_column_value($column, $post_id);
}
public static function render_photo_column($column, $post_id) {
self::render_column_value($column, $post_id);
}
private static function render_column_value($column, $post_id) {
switch ($column) {
case 'order':
echo esc_html(get_post_meta($post_id, '_order', true));
break;
case 'slug':
echo esc_html(get_post_field('post_name', $post_id));
break;
case 'thumbnail':
echo has_post_thumbnail($post_id)
? get_the_post_thumbnail($post_id, [60, 60])
: '—';
break;
}
}
//
public static function sortable_album_columns($columns) {
$columns['order'] = 'order';
return $columns;
}
public static function sortable_photo_columns($columns) {
$columns['order'] = 'order';
return $columns;
}
//
public static function handle_custom_ordering($query) {
if (!is_admin() || !$query->is_main_query()) return;
if ($query->get('orderby') === 'order') {
$query->set('meta_key', '_order');
$query->set('orderby', 'meta_value_num');
}
}
Meta Boxes: Album & Order
Photos need to be associated with an album and have a defined order. We create two meta boxes for this:
- One for
gallery_photo: album dropdown + order field - One for
gallery_album: just the order field
//
public static function add_meta_boxes() {
add_meta_box(
'photo_album_meta',
__('Album & Order', 'oz'),
[self::class, 'render_photo_meta_box'],
'gallery_photo'
);
add_meta_box(
'album_order_meta',
__('Order', 'oz'),
[self::class, 'render_album_meta_box'],
'gallery_album'
);
}
//
public static function render_photo_meta_box($post) {
$albums = get_posts(['post_type' => 'gallery_album', 'numberposts' => -1]);
$selected_album = get_post_meta($post->ID, '_album_id', true);
$order = get_post_meta($post->ID, '_order', true);
echo '<label>Album:</label> <select name="album_id">';
foreach ($albums as $album) {
printf(
'<option value="%d"%s>%s</option>',
$album->ID,
selected($selected_album, $album->ID, false),
esc_html($album->post_title)
);
}
echo '</select><br><br>';
echo '<label>Order:</label> ';
echo '<input type="number" name="photo_order" value="' . esc_attr($order) . '" />';
}
public static function render_album_meta_box($post) {
$order = get_post_meta($post->ID, '_order', true);
echo '<label>Order:</label> ';
echo '<input type="number" name="album_order" value="' . esc_attr($order) . '" />';
}
And save the meta data manually:
public static function save_photo_meta($post_id) {
// verify for auto save
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (isset($_POST['album_id'])) {
$album_id = intval($_POST['album_id']);
update_post_meta($post_id, '_album_id', $album_id);
// only if parent is another
$post = get_post($post_id);
if ($post && intval($post->post_parent) !== $album_id) {
// avoid recursion
remove_action('save_post_gallery_photo', [self::class, 'save_photo_meta']);
wp_update_post([
'ID' => $post_id,
'post_parent' => $album_id,
]);
add_action('save_post_gallery_photo', [self::class, 'save_photo_meta']);
}
}
if (isset($_POST['photo_order'])) {
update_post_meta($post_id, '_order', intval($_POST['photo_order']));
}
}
public static function save_album_meta($post_id) {
if (isset($_POST['album_order'])) {
update_post_meta($post_id, '_order', intval($_POST['album_order']));
}
}
Fixing the Global $post on Gallery Photo Pages
Due to how we rewrite URLs for gallery photos, $post may not be set properly. This fix ensures all template tags behave as expected:
public static function force_global_post() {
if (is_singular('gallery_photo') && empty($GLOBALS['post'])) {
global $wp_query;
if ($wp_query->get_queried_object() instanceof WP_Post) {
$GLOBALS['post'] = $wp_query->get_queried_object();
setup_postdata($GLOBALS['post']);
}
}
}
Now we should update the init function.
public static function init() {
add_action('init', [self::class, 'register_post_types']);
add_filter('post_type_link', [self::class, 'filter_photo_permalink'], 10, 2);
add_action('admin_menu', [self::class, 'add_gallery_menu'], 9);
add_action('admin_menu', [self::class, 'remove_menu_duplicate'], 999);
// Listings: custom columns and sorting
add_filter('manage_gallery_album_posts_columns', [self::class, 'add_album_columns']);
add_filter('manage_gallery_photo_posts_columns', [self::class, 'add_photo_columns']);
add_action('manage_gallery_album_posts_custom_column', [self::class, 'render_album_column'], 10, 2);
add_action('manage_gallery_photo_posts_custom_column', [self::class, 'render_photo_column'], 10, 2);
add_filter('manage_edit-gallery_album_sortable_columns', [self::class, 'sortable_album_columns']);
add_filter('manage_edit-gallery_photo_sortable_columns', [self::class, 'sortable_photo_columns']);
add_action('pre_get_posts', [self::class, 'handle_custom_ordering']);
// Meta boxes
add_action('add_meta_boxes', [self::class, 'add_meta_boxes']);
add_action('save_post_gallery_photo', [self::class, 'save_photo_meta']);
add_action('save_post_gallery_album', [self::class, 'save_album_meta']);
//
add_action('wp', [self::class, 'force_global_post']);
}
3. Creating the class-ozpg-settings.php file
We’ll start with
<?php
defined('ABSPATH') || exit;
class OZPG_Settings {
public static function init() {
add_action('admin_menu', [self::class, 'add_settings_page']);
}
and finish with
}
Adding a Settings Submenu
We hook into admin_menu and register a new submenu item under our “Oz Gallery” top-level menu:
public static function add_settings_page() {
add_submenu_page(
'oz-gallery',
__('Gallery Settings', 'oz-photo-gallery'),
__('Settings', 'oz-photo-gallery'),
'manage_options',
'oz-gallery-settings',
[self::class, 'render_settings_page']
);
}
This ensures that the settings page appears as Oz Gallery → Settings in the WordPress admin.
Rendering the Settings Form
The form allows users to customise:
- The base slug for all gallery URLs
- The internal cache duration in hours
<input name="oz_gallery_root_slug" type="text" value="..." />
<input name="oz_gallery_cache_duration" type="number" value="..." />
Each field is wrapped with a description to explain what it controls. Nonces are used to protect the form from CSRF attacks:
<?php wp_nonce_field('oz_gallery_settings'); ?>
Handling Form Submission Securely
When the form is submitted, we:
- Sanitise the slug using
sanitize_title - Typecast the cache duration as an integer
- Update options using
update_option - Trigger a rewrite rule flush by setting
ozpg_flush_rewrite
public static function render_settings_page() {
// Handle cache reset
if (isset($_POST['oz_gallery_reset_cache']) && check_admin_referer('oz_gallery_settings')) {
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_oz_gallery_photos_%'");
echo '<div class="notice notice-success"><p>Gallery cache cleared.</p></div>';
}
// Handle saving options
if (isset($_POST['oz_gallery_settings_submit']) && check_admin_referer('oz_gallery_settings')) {
update_option('oz_gallery_root_slug', sanitize_title($_POST['oz_gallery_root_slug']));
update_option('oz_gallery_cache_duration', (int) $_POST['oz_gallery_cache_duration']);
update_option('ozpg_flush_rewrite', true);
echo '<div class="notice notice-success"><p>Settings saved.</p></div>';
flush_rewrite_rules();
}
$root_slug = get_option('oz_gallery_root_slug', 'oz-gallery');
$cache_duration = get_option('oz_gallery_cache_duration', 12);
?>
<div class="wrap">
<h1><?= esc_html__('Gallery Settings', 'oz-photo-gallery') ?></h1>
<form method="post">
<?php wp_nonce_field('oz_gallery_settings'); ?>
<table class="form-table">
<tr>
<th><label for="oz_gallery_root_slug"><?= esc_html__('Root URL Slug', 'oz-photo-gallery') ?></label></th>
<td>
<input name="oz_gallery_root_slug" type="text" id="oz_gallery_root_slug"
value="<?= esc_attr($root_slug) ?>" placeholder="oz-gallery" class="regular-text">
<p class="description">This defines the gallery root URL. For example, <code>/oz-gallery/</code>.</p>
</td>
</tr>
<tr>
<th><label for="oz_gallery_cache_duration"><?= esc_html__('Cache Duration (hours)', 'oz-photo-gallery') ?></label></th>
<td>
<input name="oz_gallery_cache_duration" type="number" id="oz_gallery_cache_duration"
value="<?= esc_attr($cache_duration) ?>" min="1" max="168">
<p class="description">How long to cache photo lists, in hours.</p>
</td>
</tr>
</table>
<p class="submit">
<button type="submit" name="oz_gallery_settings_submit" class="button button-primary">Save Changes</button>
<button type="submit" name="oz_gallery_reset_cache" class="button">Reset Cache</button>
</p>
</form>
</div>
<?php
}
After saving, a success message is displayed via an admin notice.
Manual Cache Reset
We also include a manual cache reset button that clears all oz_gallery_photos_* transients:
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_oz_gallery_photos_%'");
This is triggered by a POST form with another nonce check. The interface provides immediate visual feedback when the cache is cleared.
Why This Matters
This admin settings page gives site owners control over:
- Gallery permalink structure (essential for SEO & branding)
- Performance (through caching duration)
- Manual reset of cached views (e.g. after batch photo uploads)
The code adheres to WordPress standards — using capability checks, nonces, sanitisation, and admin hooks. It’s scalable and ready for further options like:
- Default image sizes
- Gallery behaviour (grid vs. slider)
- Integration toggles (REST API, OpenGraph)
4. Bonus: Dynamic Gallery Slug via Helper Function in helper.php
In Part 1, the gallery base slug was hardcoded:
function oz_get_gallery_slug() {
return 'gallery';
}
Now, we make it dynamic — so users can customise the root URL from the settings page:
function oz_get_gallery_slug() {
return trim(get_option('oz_gallery_root_slug', 'oz-gallery'), '/');
}
This ensures that all registered post types, rewrite rules, and permalinks will consistently reflect the admin-defined structure — such as:
/gallery//portfolio//photo-gallery/
No manual changes needed in code — just update the setting in Oz Gallery → Settings.
With this, your plugin becomes much more flexible and user-friendly — exactly what’s expected in a production-grade WordPress plugin.