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:
<?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.
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:
<?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');
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:
<?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]:
<?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');
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:
<?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
Build the Portfolio Manager plugin
Move Week 19's CPT into a proper plugin, and add a shortcode for embedding projects anywhere.
Requirements:
- Create a
portfolio-managerplugin with a correctly formatted header comment, and organize it intoincludes/post-types.phpandincludes/shortcodes.php, both required from the main file. - Move the
projectCPT andproject_typetaxonomy registrations from your theme'sfunctions.phpinto the plugin, prefixing every function uniquely. - Add activation/deactivation hooks that flush rewrite rules.
- Build a
[recent_projects]shortcode withcountandtypeattributes, reusing last week'sproject-cardtemplate part. - 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.
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?
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_?
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?
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?
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.