WP Plugin Structure
(NOTE: hey what about php namespacing? is that still not done in WP land??? bci dont think wp needs to run all plugin code in teh global space)
Chapter 14 ended on a decision: theme-scoped logic goes in functions.php, anything that should outlive the theme goes in a plugin. This chapter builds the plugin. You’ll create a custom post type for portfolio entries, hook it up correctly, handle activation, and end with a working plugin you can activate in wp-admin. The theme stays in charge of how things look. The plugin takes charge of what exists.
Theme versus plugin: the distinction that matters
A theme controls presentation. A plugin controls functionality. That’s the one-line version, and it holds up, but the line that actually decides where code goes is sharper than that.
Picture a client’s site a year from now. They’ve registered a custom post type called portfolio, and it holds forty published portfolio entries. Then they hire a new designer who installs a new theme. If the register_post_type() call lived in the old theme’s functions.php, it stopped running the moment that theme was deactivated. The forty entries are still in the database, but the post type that organized them no longer exists, so they vanish from the admin sidebar. The content is stranded.
That’s the test. The post type isn’t presentation. It’s a structure the client’s content depends on, and content structures shouldn’t be tied to a design choice. It belongs in a plugin, where it survives every theme change.
Compare that to a shortcode that exists purely to lay out a section of this specific theme, or a helper that formats output for this theme’s template parts. Those genuinely are tied to how this theme displays content. If the theme goes, they should go with it. functions.php is fine for those.
In practice, when you’re not sure, make it a plugin. Plugins are portable. A plugin that could have been theme code costs you almost nothing. Theme code that should have been a plugin costs the client their content.
When to write a plugin
Three practical signals tell you the work belongs in a plugin.
The functionality needs to work regardless of the active theme. Custom post types, custom taxonomies, anything that defines what content the site holds: theme-independent by definition.
You’re building something you might reuse. If you can imagine carrying this feature to another client’s site, a plugin is a reusable unit and functions.php is not.
The feature is large enough to warrant its own versioning and update path. A plugin has its own version number, its own changelog, its own activation lifecycle. Once a feature is substantial, that separation is worth having.
The plugin in this chapter hits the first signal squarely. A portfolio post type defines content structure, and content structure should not depend on a theme.
Plugin file structure
A plugin lives in its own folder inside wp-content/plugins/. The minimal structure for what you’re building:
wp-content/plugins/tutorials-plugin/
tutorials-plugin.php ← main file, the one WordPress reads
includes/
class-tutorials-cpt.php
assets/
css/
js/tutorials-plugin.php is the main file. WordPress reads it, and only it, to discover the plugin. Everything else is pulled in from there. The includes/ directory holds the rest of the code, split into focused files the way Chapter 14 split the theme’s inc/ directory. The assets/ directory holds any CSS or JavaScript the plugin needs, kept separate from the theme’s assets.
The folder name and the main file name conventionally match, but they don’t have to. What matters is that the main file contains the header WordPress looks for.
The plugin header
WordPress identifies a plugin by a structured comment block at the top of the main file. No header, no plugin: the folder sits in wp-content/plugins/ and WordPress ignores it. With the header, the plugin appears on the Plugins screen in wp-admin, ready to activate.
<?php
/**
* Plugin Name: Tutorials Plugin
* Plugin URI: https://justinchick.com
* Description: Custom post types and utility functions for the tutorials project.
* Version: 1.0.0
* Author: Justin Chick
* Author URI: https://justinchick.com
* License: GPL-2.0+
* Text Domain: tutorials-plugin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}The header fields parallel the theme’s style.css header from Chapter 12. Plugin Name is the only required field; it’s what shows on the Plugins screen. Description shows there too. Version drives WordPress’s update checks and your own changelog. Author and the URI fields are attribution. License should be GPL-compatible, since WordPress itself is GPL. Text Domain is the string used to group the plugin’s translatable text.
The block below the header is not optional. ABSPATH is a constant WordPress defines when it boots. It’s present whenever the plugin file is loaded by WordPress and absent if someone requests the file directly over the web, by guessing its URL. The check if ( ! defined( 'ABSPATH' ) ) { exit; } means: if WordPress didn’t load me, stop immediately. Without it, a direct request to the file runs your PHP outside the WordPress environment, where functions are undefined and behavior is unpredictable. Every PHP file in a plugin should have this guard.
Registering a custom post type
The portfolio post type is the plugin’s job. Keep the registration in its own file under includes/.
<?php
/**
* Custom post type registration.
*
* @package tutorials-plugin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Register the portfolio custom post type.
*
* @return void
*/
function tutorials_register_portfolio_cpt() {
$labels = array(
'name' => __( 'Portfolio', 'tutorials-plugin' ),
'singular_name' => __( 'Portfolio Item', 'tutorials-plugin' ),
'add_new_item' => __( 'Add New Portfolio Item', 'tutorials-plugin' ),
'edit_item' => __( 'Edit Portfolio Item', 'tutorials-plugin' ),
'menu_name' => __( 'Portfolio', 'tutorials-plugin' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-portfolio',
'rewrite' => array( 'slug' => 'portfolio' ),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
'show_in_rest' => true,
);
register_post_type( 'portfolio', $args );
}
add_action( 'init', 'tutorials_register_portfolio_cpt' );register_post_type() takes two arguments: the post type’s identifier (portfolio, used internally and in URLs) and an array of arguments.
The arguments worth understanding here. labels is an array of the human-readable strings WordPress shows throughout the admin: the menu name, the “Add New” button, the editor heading. public set to true makes the post type visible on the front end and in the admin. has_archive set to true tells WordPress to generate an archive page listing all portfolio entries, reachable at /portfolio/. supports lists the editor features the post type gets: a title field, the content editor, a featured image, an excerpt. rewrite sets the URL slug. show_in_rest set to true exposes the post type to the REST API and, with it, the block editor.
The whole thing is hooked to init. Custom post types must be registered on init, every time WordPress loads, not once. WordPress doesn’t store the registration; it expects code to re-register the post type on each request. That’s why this is an add_action( 'init', ... ) and not a one-time setup call.
Once this is in place and the plugin is active, “Portfolio” appears in the wp-admin sidebar, with its own list of entries and its own editor, exactly like Posts and Pages.
Activation and deactivation hooks
Most of a plugin’s code runs on every request. A small amount should run exactly once, when the plugin is switched on or off. That’s what register_activation_hook() and register_deactivation_hook() are for.
The textbook case is rewrite rules. WordPress builds its URL routing, the rules that map /portfolio/ to the portfolio archive, and caches it. When you register a new post type with has_archive, the new archive URL isn’t in that cache yet, so visiting /portfolio/ returns a 404 until the cache is rebuilt. flush_rewrite_rules() rebuilds it. It’s an expensive operation, so you don’t want it running on every request. You want it running once, at activation, right after the post type is registered.
require plugin_dir_path( __FILE__ ) . 'includes/post-types.php';
register_activation_hook( __FILE__, 'tutorials_plugin_activate' );
/**
* Run once when the plugin is activated.
*
* @return void
*/
function tutorials_plugin_activate() {
tutorials_register_portfolio_cpt();
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'tutorials_plugin_deactivate' );
/**
* Run once when the plugin is deactivated.
*
* @return void
*/
function tutorials_plugin_deactivate() {
flush_rewrite_rules();
}register_activation_hook() takes the path to the main plugin file as its first argument. __FILE__ is a PHP magic constant that resolves to the path of the file it appears in, so passing __FILE__ from the main plugin file is correct. The second argument is the callback.
The activation callback registers the post type and then flushes the rules, in that order. The post type has to exist before the rewrite rules can include its archive URL. The deactivation callback flushes again: with the plugin off, the post type is gone, and flushing clears its archive rule from the cache so the URL stops resolving to a now-nonexistent post type.
plugin_dir_path( __FILE__ ) returns the filesystem path to the plugin’s directory, the plugin equivalent of the theme’s get_template_directory(). It’s how the main file pulls in includes/post-types.php without hardcoding a path.
Avoiding naming collisions
WordPress runs every plugin’s code in one shared global namespace. Your tutorials_plugin_activate function lives in the same space as every function from every other active plugin and from WordPress core. If two of them define a function with the same name, PHP throws a fatal error and the site goes down.
The defense is prefixing. Every function, every constant, every global variable a plugin defines gets a prefix unique to that plugin. This plugin uses tutorials_. Every function name in the code above starts with it: tutorials_register_portfolio_cpt, tutorials_plugin_activate, tutorials_plugin_deactivate. A generic name like register_cpt or activate is a collision waiting to happen. tutorials_register_portfolio_cpt is specific enough that nothing else will claim it.
PHP namespaces and class-based structure are another way to handle this, and on a large plugin they’re the better answer. The includes/class-tutorials-cpt.php file in the structure above hints at that path. For a plugin this size, plain prefixed functions are clear, conventional, and enough. As a plugin grows, moving to classes and namespaces is a natural next step, but it isn’t a requirement for getting a working plugin shipped.
A working example
Putting it together, the main plugin file looks like this:
<?php
/**
* Plugin Name: Tutorials Plugin
* Plugin URI: https://justinchick.com
* Description: Custom post types and utility functions for the tutorials project.
* Version: 1.0.0
* Author: Justin Chick
* Author URI: https://justinchick.com
* License: GPL-2.0+
* Text Domain: tutorials-plugin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require plugin_dir_path( __FILE__ ) . 'includes/post-types.php';
register_activation_hook( __FILE__, 'tutorials_plugin_activate' );
/**
* Run once when the plugin is activated.
*
* @return void
*/
function tutorials_plugin_activate() {
tutorials_register_portfolio_cpt();
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'tutorials_plugin_deactivate' );
/**
* Run once when the plugin is deactivated.
*
* @return void
*/
function tutorials_plugin_deactivate() {
flush_rewrite_rules();
}If you registered a portfolio post type in the theme’s functions.php while working through Chapter 14, this is the moment to move it. Delete the registration from the theme. The tutorials_register_portfolio_cpt() function and its add_action( 'init', ... ) now live in the plugin’s includes/post-types.php. The behavior on the front end doesn’t change. What changes is where the code lives, and that’s the whole point: the content structure is now independent of the theme.
The theme still owns the display. WordPress’s template hierarchy looks for single-portfolio.php to render a single portfolio entry and archive-portfolio.php to render the archive. Those files belong in the theme, because they’re presentation. The plugin defines that portfolio entries exist; the theme defines what they look like. That split is the theme-and-plugin separation working exactly as intended.
Installing and activating
There’s no build step and no special installation. Place the tutorials-plugin folder in wp-content/plugins/. Go to the Plugins screen in wp-admin. “Tutorials Plugin” appears in the list, with the name and description from the header you wrote. Click Activate.
On activation, tutorials_plugin_activate() runs: the post type registers and the rewrite rules flush. “Portfolio” appears in the sidebar, and /portfolio/ resolves to the archive. The plugin is live.
Commit your work
You’ve built a working plugin: a main file with a proper header, a custom post type registered on init, and activation and deactivation hooks handling rewrite rules. Commit it.