Week 20: Plugin Development I — Hooks, Filters & Shortcodes

Week 19 flagged that a Custom Post Type belongs in a plugin, not a theme — this week builds that plugin properly. Real plugin structure, hooks and filters used deliberately rather than just copied from an example, and shortcodes: WordPress's mechanism for embedding dynamic content inside ordinary post content.

Module 18 of 28 Week 20 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Structure a real WordPress plugin with proper activation/deactivation handling
  • Use hooks & filters deliberately, not just by copying examples
  • Build a shortcode that renders dynamic content inside post content

1. Plugin Anatomy

A plugin is a folder in wp-content/plugins/ with a main PHP file carrying a comment header — WordPress reads that header to list it in the admin's Plugins screen, the same idea as a theme's style.css header from last week:

wp-content/plugins/portfolio-manager/portfolio-manager.php
<?php
/**
 * Plugin Name: Portfolio Manager
 * Description: Adds a Projects post type with a client-facing shortcode.
 * Version: 1.0.0
 * Author: You
 */

if (!defined('ABSPATH')) {
    exit; // block direct access -- this file must only ever run inside WordPress
}

define('PM_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('PM_PLUGIN_URL', plugin_dir_url(__FILE__));

require_once PM_PLUGIN_DIR . 'includes/post-types.php';
require_once PM_PLUGIN_DIR . 'includes/shortcodes.php';

if (!defined('ABSPATH')) { exit; } is a security convention worth always including — it stops the file from executing if someone requests it directly by URL rather than through WordPress's normal loading process, where ABSPATH is always defined.

terminal
wp plugin activate portfolio-manager

2. Moving the CPT Into a Plugin

Exactly the registration from Week 19, relocated to where it belongs — data definitions live in a plugin, independent of whichever theme happens to be active:

wp-content/plugins/portfolio-manager/includes/post-types.php
<?php

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

function pm_register_project_post_type() {
    register_post_type('project', [
        'labels' => ['name' => 'Projects', 'singular_name' => 'Project'],
        'public' => true,
        'has_archive' => true,
        'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
        'show_in_rest' => true,
    ]);
}
add_action('init', 'pm_register_project_post_type');
The pm_ prefix is a real convention, not decoration

WordPress has no built-in namespacing for plain functions the way Composer/PSR-4 does (Week 4) — every activated plugin's functions share one global namespace. A short, unique prefix on every function name (or wrapping everything in a class, or using a real PHP namespace, all valid approaches) is how plugin authors avoid a fatal "cannot redeclare function" collision with another plugin.

3. Activation & Deactivation

A plugin often needs setup when activated and cleanup when deactivated — flushing WordPress's rewrite rules (so the new CPT's archive URL works immediately) is a classic example:

portfolio-manager.php — appended
<?php

function pm_activate() {
    pm_register_project_post_type(); // must run before flushing, so the new rules exist
    flush_rewrite_rules();
}
register_activation_hook(__FILE__, 'pm_activate');

function pm_deactivate() {
    flush_rewrite_rules();
}
register_deactivation_hook(__FILE__, 'pm_deactivate');

This is exactly why Week 19's exercise hint recommended visiting Settings → Permalinks manually — register_activation_hook automates that same rewrite-rule flush, running it once at the moment the plugin is activated instead of requiring a manual admin click every time.

4. Shortcodes

A shortcode lets an editor embed dynamic, plugin-generated content inside otherwise-static post content — typed directly into the block editor or classic editor as [bracket_syntax]:

wp-content/plugins/portfolio-manager/includes/shortcodes.php
<?php

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

function pm_recent_projects_shortcode($atts): string {
    $atts = shortcode_atts([
        'count' => 3,
        'type' => '', // optional project_type slug to filter by
    ], $atts);

    $args = [
        'post_type' => 'project',
        'posts_per_page' => (int) $atts['count'],
    ];

    if ($atts['type'] !== '') {
        $args['tax_query'] = [[
            'taxonomy' => 'project_type',
            'field' => 'slug',
            'terms' => $atts['type'],
        ]];
    }

    $query = new WP_Query($args);
    if (!$query->have_posts()) {
        return '<p>No projects found.</p>';
    }

    ob_start(); // capture output into a string instead of echoing directly
    while ($query->have_posts()) : $query->the_post();
        get_template_part('template-parts/project-card');
    endwhile;
    wp_reset_postdata();

    return ob_get_clean();
}
add_shortcode('recent_projects', 'pm_recent_projects_shortcode');
usage — typed directly into a post's content
Check out our latest work:

[recent_projects count="4" type="web"]

shortcode_atts() merges the editor-supplied attributes over sensible defaults — [recent_projects] with no attributes still works, falling back to count=3 and no type filter. Notice a shortcode callback must return its HTML, never echo it directly — that's why the function buffers output with ob_start()/ob_get_clean() around the Loop, since get_template_part() echoes.

5. Hook Priority & Arguments

add_action/add_filter both accept a priority and an argument count beyond the two shown so far — worth knowing precisely once multiple plugins hook into the same point:

priority and arguments
<?php

// add_action(hook, callback, priority = 10, accepted_args = 1)
add_action('init', 'pm_register_project_post_type', 5);  // runs earlier than the default (10)
add_action('init', 'some_other_plugins_function', 20);   // runs later

// A filter that needs more than the one value being filtered
function pm_adjust_title(string $title, int $post_id): string {
    if (get_post_type($post_id) === 'project') {
        return $title . ' (Project)';
    }
    return $title;
}
add_filter('the_title', 'pm_adjust_title', 10, 2); // 2 -> also receive $post_id

Lower priority numbers run first. When two plugins hook the same filter, this is the only lever controlling which one's transformation "wins" if they conflict — a detail that matters far more once you're debugging a real multi-plugin site than it seems from a single-plugin example.

6. Hands-on Exercise

Hands-on

Build the Portfolio Manager plugin

Move Week 19's CPT into a proper plugin, and add a shortcode for embedding projects anywhere.

Requirements:

  1. Create a portfolio-manager plugin with a correctly formatted header comment, and organize it into includes/post-types.php and includes/shortcodes.php, both required from the main file.
  2. Move the project CPT and project_type taxonomy registrations from your theme's functions.php into the plugin, prefixing every function uniquely.
  3. Add activation/deactivation hooks that flush rewrite rules.
  4. Build a [recent_projects] shortcode with count and type attributes, reusing last week's project-card template part.
  5. Deactivate your theme's old CPT registration entirely, activate the plugin, and confirm every existing project post and its archive/single templates still work exactly as before.
Hint

Test the "swap the theme, projects survive" claim from Week 19 for real — switch to a default WordPress theme (like Twenty Twenty-Four) with the plugin active, and confirm your Projects content is still intact and manageable through the admin, just without your custom templates.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does if (!defined('ABSPATH')) { exit; } at the top of a plugin file actually protect against?

ABSPATH is only defined when WordPress itself has bootstrapped and loaded the file as part of its normal execution. If someone requests the plugin file directly by URL (e.g. yoursite.com/wp-content/plugins/portfolio-manager/portfolio-manager.php), ABSPATH won't be set, and this check stops the file from running standalone — outside WordPress's context, where functions like register_post_type() wouldn't even exist and could cause fatal errors or expose unintended behavior.

Q2

Why prefix every plugin function name with something like pm_?

Plain PHP functions declared without a namespace all share one global namespace across every active plugin and theme on the site. Two plugins both defining a function simply named register_post_types() causes a fatal "cannot redeclare function" error the moment both are active. A unique prefix (or a real namespace, or wrapping everything in a class) is how plugin authors avoid that collision.

Q3

Why must a shortcode callback return its HTML instead of echo-ing it directly?

WordPress needs the string back so it can insert it at the exact point in the post content where [recent_projects] appeared — content that might be in the middle of a paragraph, or wrapped by other formatting. An echo-ing shortcode would print its output immediately wherever content is being processed, rather than in the correct position, and often before the surrounding content has even finished rendering.

Q4

Two plugins both hook the_title. Plugin A registers at priority 5, plugin B at priority 20. Which one's filter runs first?

Plugin A, at priority 5 — lower priority numbers run earlier (the default, when unspecified, is 10). Its filtered result is then passed as the input to plugin B's filter at priority 20, which runs afterward and receives whatever value plugin A already returned, not the original untouched title.