Skip to main content

πŸ› οΈ 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.

Responsibility split between the portfolio plugin and the theme The plugin box owns the custom post type, taxonomy, meta fields, and shortcode. The theme box owns templates, styles, and layout. They connect through a thin layer of template helper functions. Plugin β€” Functionality survives a theme switch Custom post type: portfolio Taxonomy: portfolio_category Meta fields: client, date, URL Shortcode: [portfolio] Template helper functions register_post_type Β· register_taxonomy Theme β€” Presentation swappable at any time archive-portfolio.php single-portfolio.php style.css Β· grid & filters functions.php Β· enqueue assets Customizer options calls helpers, never defines data helpers calls
Figure 1 β€” The plugin owns the data and exposes small helper functions; the theme calls those helpers to render. Cross this boundary in one direction only.

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.

flowchart LR A[Milestone 1
Plugin scaffold
+ post type] --> B[Milestone 2
Meta boxes
save & escape] B --> C[Milestone 3
Theme templates
+ integration] C --> D[Review
checklist &
rubric]
MilestoneYou'll have builtDemoable when…
1 β€” FoundationAn activatable plugin registering the portfolio post type and its category taxonomy"Portfolio" appears in the admin menu and you can add an item
2 β€” DataA secure meta box capturing client, date, and project URLYou save an item and the values persist on reload
3 β€” PresentationA theme with archive + single templates plus a [portfolio] shortcodeThe 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:

flowchart LR A[Render box] -->|nonce field| B[User edits & saves] B -->|verify nonce| C[Check autosave] C -->|check capability| D[Sanitize input] D -->|update_post_meta| E[Stored] E -->|escape on output| F[Displayed]

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
  • ☐ portfolio post type appears in the admin menu with its icon
  • ☐ portfolio_category taxonomy 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.css header 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.

DimensionNeeds workGoodExcellent
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.php template that shows the client, date, and a linked project URL via your helper.
  • Register your meta fields with register_post_meta() and show_in_rest so they're editable in the block editor sidebar too.
  • Add a category filter bar to the archive using CSS-only :target or 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_unslash before 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

πŸš€ 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.