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 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.

How to Build a WordPress Plugin from Scratch: Part 2 – Custom Post Types and Admin UI
▶ Play

Table of Contents

  1. Updating the Main Plugin File
  2. Updating class-ozpg-post-types.php
    1. Registering Custom Post Types: Albums & Photos
    2. Setting Up Pretty Permalinks
    3. Custom Admin Menu
    4. Custom Admin Columns
    5. Meta Boxes: Album & Order
    6. Sortable Admin Columns
    7. Fixing the Global $post
  3. Creating the class-ozpg-settings.php File
    1. Adding a Settings Submenu
    2. Rendering the Settings Form
    3. Handling Form Submission Securely
    4. Manual Cache Reset
  4. 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 photos
  • gallery_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.

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
&lt;input name="oz_gallery_root_slug" type="text" value="..." /&gt;
&lt;input name="oz_gallery_cache_duration" type="number" value="..." /&gt;

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-&gt;query("DELETE FROM {$wpdb-&gt;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.