π οΈ Weekend Project: PHP & WordPress
Time to put the whole module to work. Over one focused weekend you'll ship a Portfolio Showcase β a WordPress site whose custom theme and custom plugin cooperate to solve a real problem for creative professionals. This is a guided build with milestones, checkpoints, and copy-and-adapt code, not a spec to stare at.
π― Learning Objectives
By the end of this project, you will be able to:
- Scaffold a WordPress custom theme and plugin from scratch with correct file structure and headers
- Register a custom post type and taxonomy in a plugin, keeping content and presentation cleanly separated
- Build secure meta boxes that validate, sanitize, and escape data on the round trip
- Integrate a theme and plugin safely with feature detection and graceful fallbacks
- Evaluate your build against a "what good looks like" rubric before you call it done
Estimated Time: One weekend (12β20 focused hours) β’ Difficulty: Intermediate β Advanced
Hands-on: This entire lesson is the exercise β you build the Portfolio Showcase milestone by milestone.
In This Lesson
The Brief & Why It's Structured This Way
Creative professionals β photographers, designers, illustrators β need a clean, self-managed way to show their work. A generic blog theme buries images in a river of posts; a page builder locks their content inside proprietary shortcodes. Your job is to give them something portable and maintainable: their projects live in the database as first-class content, and any theme can render them.
That last sentence is the whole design principle of this project, and it's the single most important WordPress lesson in the module:
π The golden rule of WordPress architecture
Plugins provide functionality; themes provide presentation. Data that must survive a theme switch β custom post types, taxonomies, meta fields, shortcodes β belongs in a plugin. Anything about how it looks β templates, CSS, layout β belongs in the theme. If a user changes their theme and their portfolio disappears, you put the data in the wrong place.
We'll work in three milestones, and each ends with something you can actually see working. That's deliberate: shipping a small working slice beats a big broken one, and it keeps motivation high across a two-day sprint.
π‘ Before you start β set up a local site
Use a modern local stack β wp-env, Local, or DDEV β running PHP 8.2+ and current WordPress. Turn on debugging in wp-config.php so mistakes are loud, not silent:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true ); // writes to wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false );
Architecture: Theme vs. Plugin
Here is how responsibility splits across the two pieces you'll build. Notice the flow of ownership: the plugin owns the data, the theme owns the pixels, and they meet at a thin, well-defined boundary of template functions.
A quick way to sanity-check any feature you're about to build: ask "if the user switched themes tomorrow, should this still exist?" If yes, it's plugin work. If no, it's theme work.
The Milestone Map
Three milestones, each independently demoable. Don't move on until the current one works in the browser.
Plugin scaffold
+ post type] --> B[Milestone 2
Meta boxes
save & escape] B --> C[Milestone 3
Theme templates
+ integration] C --> D[Review
checklist &
rubric]
| Milestone | You'll have built | Demoable when⦠|
|---|---|---|
| 1 β Foundation | An activatable plugin registering the portfolio post type and its category taxonomy | "Portfolio" appears in the admin menu and you can add an item |
| 2 β Data | A secure meta box capturing client, date, and project URL | You save an item and the values persist on reload |
| 3 β Presentation | A theme with archive + single templates plus a [portfolio] shortcode | The public archive renders your items in a grid |
Milestone 1 β Scaffold the Plugin
Create wp-content/plugins/portfolio-showcase/portfolio-showcase.php. The header comment is what makes WordPress recognize it as a plugin; the WPINC guard blocks anyone from loading the file directly.
<?php
/**
* Plugin Name: Portfolio Showcase
* Description: Custom post type, taxonomy, and shortcode for a creative portfolio.
* Version: 1.0.0
* Requires PHP: 8.2
* Requires at least: 6.4
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: portfolio-showcase
*/
// Abort if accessed directly.
if ( ! defined( 'WPINC' ) ) {
die;
}
define( 'PORTFOLIO_SHOWCASE_VERSION', '1.0.0' );
Now register the post type and taxonomy. We hook both to init. Setting 'show_in_rest' => true is what makes the block editor (Gutenberg) work for your custom content β skip it and editors get the old classic editor by surprise.
/**
* Register the "portfolio" custom post type.
*/
function portfolio_showcase_register_cpt() {
$labels = array(
'name' => __( 'Portfolio Items', 'portfolio-showcase' ),
'singular_name' => __( 'Portfolio Item', 'portfolio-showcase' ),
'add_new_item' => __( 'Add New Project', 'portfolio-showcase' ),
'edit_item' => __( 'Edit Project', 'portfolio-showcase' ),
'menu_name' => __( 'Portfolio', 'portfolio-showcase' ),
);
register_post_type( 'portfolio', array(
'labels' => $labels,
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-portfolio',
'menu_position'=> 20,
'rewrite' => array( 'slug' => 'portfolio' ),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
'show_in_rest' => true, // enables the block editor + REST API
) );
}
add_action( 'init', 'portfolio_showcase_register_cpt' );
/**
* Register the hierarchical "portfolio_category" taxonomy.
*/
function portfolio_showcase_register_taxonomy() {
register_taxonomy( 'portfolio_category', array( 'portfolio' ), array(
'labels' => array(
'name' => __( 'Portfolio Categories', 'portfolio-showcase' ),
'singular_name' => __( 'Portfolio Category', 'portfolio-showcase' ),
),
'hierarchical' => true, // behaves like categories, not tags
'show_admin_column' => true,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'portfolio-category' ),
) );
}
add_action( 'init', 'portfolio_showcase_register_taxonomy' );
β οΈ The rewrite-rules gotcha
Custom post types add new URL patterns (/portfolio/my-project/). WordPress only rebuilds its rewrite rules when you visit Settings β Permalinks. Flush them properly on activation so your single/archive pages don't 404 β register the CPT first, then flush:
function portfolio_showcase_activate() {
portfolio_showcase_register_cpt();
portfolio_showcase_register_taxonomy();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'portfolio_showcase_activate' );
β Milestone 1 checkpoint
Activate the plugin. A Portfolio item appears in the admin sidebar with a portfolio icon, you can add a project, and visiting /portfolio/ no longer 404s. If it does 404, re-save your permalinks once.
Milestone 2 β Meta Boxes, Done Safely
A portfolio item needs more than a title and body: the client name, the completion date, and a live project URL. That extra data lives in post meta, edited through a meta box. This milestone is where most beginner plugins go wrong on security, so we'll do it carefully.
Every meta box has a round trip, and each leg needs a guard:
Register and render the box
function portfolio_showcase_add_meta_boxes() {
add_meta_box(
'portfolio_details',
__( 'Project Details', 'portfolio-showcase' ),
'portfolio_showcase_render_details',
'portfolio',
'side',
'high'
);
}
add_action( 'add_meta_boxes', 'portfolio_showcase_add_meta_boxes' );
function portfolio_showcase_render_details( $post ) {
// Nonce ties this form to this request β proves the save is intentional.
wp_nonce_field( 'portfolio_details_save', 'portfolio_details_nonce' );
$client = get_post_meta( $post->ID, '_portfolio_client', true );
$date = get_post_meta( $post->ID, '_portfolio_date', true );
$url = get_post_meta( $post->ID, '_portfolio_url', true );
?>
<p>
<label for="portfolio_client"><?php esc_html_e( 'Client', 'portfolio-showcase' ); ?></label>
<input type="text" id="portfolio_client" name="portfolio_client"
value="<?php echo esc_attr( $client ); ?>" class="widefat">
</p>
<p>
<label for="portfolio_date"><?php esc_html_e( 'Completed', 'portfolio-showcase' ); ?></label>
<input type="date" id="portfolio_date" name="portfolio_date"
value="<?php echo esc_attr( $date ); ?>" class="widefat">
</p>
<p>
<label for="portfolio_url"><?php esc_html_e( 'Project URL', 'portfolio-showcase' ); ?></label>
<input type="url" id="portfolio_url" name="portfolio_url"
value="<?php echo esc_url( $url ); ?>" class="widefat">
</p>
<?php
}
Save it β with all four guards
Note the wp_unslash() before sanitizing: WordPress adds slashes to $_POST, so you strip them first, then sanitize with a function matched to the data type.
function portfolio_showcase_save_details( $post_id ) {
// 1. Verify the nonce.
if ( ! isset( $_POST['portfolio_details_nonce'] )
|| ! wp_verify_nonce(
sanitize_key( $_POST['portfolio_details_nonce'] ),
'portfolio_details_save'
) ) {
return;
}
// 2. Ignore autosaves.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// 3. Check the current user is allowed to edit this post.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// 4. Sanitize each field to match its type, then store.
if ( isset( $_POST['portfolio_client'] ) ) {
update_post_meta( $post_id, '_portfolio_client',
sanitize_text_field( wp_unslash( $_POST['portfolio_client'] ) ) );
}
if ( isset( $_POST['portfolio_date'] ) ) {
update_post_meta( $post_id, '_portfolio_date',
sanitize_text_field( wp_unslash( $_POST['portfolio_date'] ) ) );
}
if ( isset( $_POST['portfolio_url'] ) ) {
update_post_meta( $post_id, '_portfolio_url',
esc_url_raw( wp_unslash( $_POST['portfolio_url'] ) ) );
}
}
add_action( 'save_post_portfolio', 'portfolio_showcase_save_details' );
π Sanitize on input, escape on output
Sanitize cleans data as it comes in (sanitize_text_field, esc_url_raw). Escape makes data safe as it goes out to a specific context (esc_html, esc_attr, esc_url). You do both β never trust stored data on the way out, because it may predate your current sanitizing rules.
Expose a helper for the theme
The theme should never call get_post_meta with your private key names directly β that couples it to your storage details. Give it a clean function instead:
/**
* Theme-facing helper: fetch a portfolio field with escaping already handled.
*/
function portfolio_showcase_field( $key, $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$value = get_post_meta( $post_id, '_portfolio_' . $key, true );
return 'url' === $key ? esc_url( $value ) : esc_html( $value );
}
β Milestone 2 checkpoint
Add a client, date, and URL to a project, save, and reload the editor β the values are still there. Try pasting <script>alert(1)</script> into the client field: after save it should be stored as harmless text, not executed anywhere it's displayed.
Milestone 3 β The Theme & Integration
Now the presentation layer. Create a minimal theme at wp-content/themes/portfolio-showcase-theme/. Every theme needs at least a style.css with a header block and an index.php; we add portfolio-specific templates on top.
style.css header + functions.php
/*
Theme Name: Portfolio Showcase Theme
Author: Your Name
Version: 1.0.0
Requires PHP: 8.2
*/
<?php
// functions.php
function portfolio_theme_setup() {
add_theme_support( 'title-tag' );
add_theme_support( 'post-thumbnails' );
add_theme_support( 'html5', array( 'gallery', 'caption', 'style', 'script' ) );
add_image_size( 'portfolio-thumb', 600, 450, true ); // hard crop
}
add_action( 'after_setup_theme', 'portfolio_theme_setup' );
function portfolio_theme_assets() {
wp_enqueue_style(
'portfolio-theme',
get_stylesheet_uri(),
array(),
wp_get_theme()->get( 'Version' )
);
}
add_action( 'wp_enqueue_scripts', 'portfolio_theme_assets' );
β οΈ Never hard-code asset URLs
Always load scripts and styles through wp_enqueue_style / wp_enqueue_script. It handles dependencies, versioning (cache-busting), and lets other plugins deregister or replace assets. Echoing a raw <link> tag breaks all of that.
archive-portfolio.php β the grid, with graceful fallback
This is the integration point. The theme checks whether the plugin's helpers exist before calling them, so if the plugin is deactivated the theme degrades instead of fataling.
<?php get_header(); ?>
<main id="primary" class="portfolio-archive">
<h1><?php post_type_archive_title(); ?></h1>
<div class="portfolio-grid">
<?php while ( have_posts() ) : the_post(); ?>
<article <?php post_class( 'portfolio-card' ); ?>>
<a href="<?php the_permalink(); ?>">
<?php if ( has_post_thumbnail() ) {
the_post_thumbnail( 'portfolio-thumb' );
} ?>
<h2><?php the_title(); ?></h2>
<?php
// Feature-detect the plugin before using its helper.
if ( function_exists( 'portfolio_showcase_field' ) ) {
$client = portfolio_showcase_field( 'client' );
if ( $client ) {
echo '<p class="client">' . $client . '</p>'; // already escaped
}
}
?>
</a>
</article>
<?php endwhile; ?>
</div>
</main>
<?php get_footer(); ?>
A responsive grid with pure CSS
No JavaScript library needed for a clean, responsive grid β modern CSS handles it in a few lines:
.portfolio-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.portfolio-card img {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
The [portfolio] shortcode (plugin side)
Back in the plugin, add a shortcode so users can drop a portfolio grid into any page. Buffer the output and always call wp_reset_postdata() after a custom WP_Query:
function portfolio_showcase_shortcode( $atts ) {
$atts = shortcode_atts(
array( 'count' => 6, 'category' => '' ),
$atts,
'portfolio'
);
$args = array(
'post_type' => 'portfolio',
'posts_per_page' => absint( $atts['count'] ),
);
if ( $atts['category'] ) {
$args['tax_query'] = array( array(
'taxonomy' => 'portfolio_category',
'field' => 'slug',
'terms' => array_map( 'sanitize_title', explode( ',', $atts['category'] ) ),
) );
}
$query = new WP_Query( $args );
ob_start();
if ( $query->have_posts() ) {
echo '<div class="portfolio-grid">';
while ( $query->have_posts() ) {
$query->the_post();
printf(
'<a class="portfolio-card" href="%s">%s<h3>%s</h3></a>',
esc_url( get_permalink() ),
get_the_post_thumbnail( null, 'portfolio-thumb' ),
esc_html( get_the_title() )
);
}
echo '</div>';
} else {
echo '<p>' . esc_html__( 'No projects yet.', 'portfolio-showcase' ) . '</p>';
}
wp_reset_postdata(); // restore the main query's global $post
return ob_get_clean();
}
add_shortcode( 'portfolio', 'portfolio_showcase_shortcode' );
β Milestone 3 checkpoint
Activate your theme, visit /portfolio/, and see your projects in a responsive grid. Drop [portfolio count="3"] on a page and confirm it renders there too. Deactivate the plugin: the theme should still load (empty), not white-screen.
Build Checklist
Work top to bottom. Tick each box only when you've verified it in the browser, not just written the code.
Foundation
- β Plugin activates with no PHP notices in
debug.log - β
portfoliopost type appears in the admin menu with its icon - β
portfolio_categorytaxonomy shows in the item editor and as an admin column - β
/portfolio/and a single item both load (no 404 after activation)
Data & security
- β Meta box renders a nonce field
- β Save path checks nonce, autosave, and capability before writing
- β Every input is sanitized on save and escaped on output
- β Malicious input (e.g. a
<script>tag) is neutralized
Presentation & integration
- β Theme has valid
style.cssheader and enqueues assets properly - β Archive grid is responsive from phone to desktop
- β Theme feature-detects plugin helpers (
function_exists) - β Site still loads with the plugin deactivated β no fatal error
- β
[portfolio]shortcode works and resets post data
What Good Looks Like
Anyone can make it "work." This rubric separates a passable submission from a professional one β it's the same lens a code reviewer or a WordPress.org plugin reviewer would use.
| Dimension | Needs work | Good | Excellent |
|---|---|---|---|
| Separation | CPT registered in the theme's functions.php |
All data in the plugin; theme only renders | Theme degrades gracefully with the plugin off |
| Security | Raw $_POST saved directly |
Nonce + capability + sanitize/escape | Type-matched sanitizers, wp_unslash, escaped on every output |
| Standards | Ad-hoc naming, no text domain | Prefixed functions, i18n-ready strings | Passes WordPress coding-standards (PHPCS) |
| Assets | Hard-coded <link>/<script> tags |
Enqueued with versions | Conditionally loaded only where needed |
| Robustness | White screen on edge cases | Handles empty results | Flushes rewrite rules on activation; no notices in debug log |
π‘ Stretch goals (if you finish early)
- Add a
single-portfolio.phptemplate that shows the client, date, and a linked project URL via your helper. - Register your meta fields with
register_post_meta()andshow_in_restso they're editable in the block editor sidebar too. - Add a category filter bar to the archive using CSS-only
:targetor a tiny vanilla-JS toggle β no jQuery. - Run PHPCS with the WordPress ruleset and clean every warning.
Summary & Quiz
π Key Takeaways
- Plugins hold data, themes hold presentation. Anything that must survive a theme switch goes in the plugin.
- Custom post types add URL rules β flush rewrite rules on activation or single pages 404.
- Meta boxes need the full round trip: nonce β autosave check β capability β sanitize β escape.
- Sanitize on input, escape on output, always both β with
wp_unslashbefore sanitizing$_POST. - Integrate with feature detection (
function_exists) so the theme never fatals when the plugin is off. - Enqueue assets; never hard-code tags. Reset post data after a custom
WP_Query.
π― Quick Quiz
Question 1: A user's portfolio projects should survive switching to a different theme. Where do you register the portfolio custom post type?
Question 2: Which sequence of guards protects a meta box save handler?
Question 3: Why does the theme wrap its call to portfolio_showcase_field() in if ( function_exists( ... ) )?
π Further Reading
- WordPress Plugin Developer Handbook
- WordPress Theme Developer Handbook
- WordPress Security APIs (nonces, sanitizing, escaping)
- Reference: register_post_type()
π What's Next?
You've now shipped a complete PHP/WordPress build end to end. Next we shift stacks and mindsets toward component-based frontends β starting with how to compose small, reusable UI pieces into larger interfaces.
π Module 24 complete!
You can register content types, secure user data, and integrate a theme with a plugin the professional way. That's real WordPress developer work.