Week 19: Theme Development II — Custom Post Types & ACF

Last week's Loop rendered ordinary posts. This week goes further — modeling content types WordPress has no built-in concept of (like "Products" or "Team Members") with Custom Post Types, your own taxonomies, structured custom fields via ACF, and template parts for markup reused across many templates.

Module 17 of 28 Week 19 of 32 ~3–4 Hours Hands-on Exercise Included

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

  • Register Custom Post Types & custom taxonomies
  • Add structured custom fields to a post type with Advanced Custom Fields
  • Extract reusable markup into template parts

1. Custom Post Types

A Custom Post Type (CPT) is a new content type stored in the same wp_posts table from Week 17, distinguished by its own post_type value — WordPress's mechanism for modeling content that isn't a blog post or page:

functions.php
<?php

function register_project_post_type() {
    register_post_type('project', [
        'labels' => [
            'name' => 'Projects',
            'singular_name' => 'Project',
        ],
        'public' => true,
        'has_archive' => true,
        'menu_icon' => 'dashicons-portfolio',
        'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
        'rewrite' => ['slug' => 'projects'],
        'show_in_rest' => true, // exposes it to the REST API -- Week 22
    ]);
}
add_action('init', 'register_project_post_type');

Once registered, "Projects" appears in the admin sidebar just like Posts, with its own listing and edit screens — register_post_type() is doing real, substantial work: creating an entire content-management interface from one function call. show_in_rest => true matters specifically for Week 22, where a headless frontend needs API access to this data.

Register CPTs in a plugin, not a theme, for real projects

This lesson registers it in functions.php for simplicity, but content types are data, not presentation — if a client ever switches themes, their Projects (and every post they've created) shouldn't disappear with the old theme. Week 20 revisits this exact CPT as a proper plugin.

2. Custom Taxonomies

Categories and tags are WordPress's built-in taxonomies. A custom taxonomy lets any post type — including your own CPTs — be classified by something domain-specific:

functions.php
<?php

function register_project_type_taxonomy() {
    register_taxonomy('project_type', 'project', [
        'labels' => ['name' => 'Project Types'],
        'hierarchical' => true, // behaves like categories (parent/child)
        'show_in_rest' => true,
    ]);
}
add_action('init', 'register_project_type_taxonomy');

hierarchical => true gives it categories' parent/child structure; false gives it tags' flat, comma-separated behavior instead. Either way, this is structurally the same many-to-many relationship as Week 6's post_tags join table and Week 11's belongsToMany — under the hood, WordPress connects posts to taxonomy terms through wp_term_relationships, the exact join-table pattern from earlier in this course.

3. Advanced Custom Fields

ACF is the standard plugin for adding structured custom fields to any post type — a "Client Name" text field and "Completion Date" date field on every Project, for example, editable from the admin without writing raw meta-box code by hand:

terminal
wp plugin install advanced-custom-fields --activate

Field groups can be built through the admin UI, or defined in code — code-based definitions are the better fit once fields are part of a theme or plugin under version control:

functions.php — excerpt
<?php

if (function_exists('acf_add_local_field_group')) {
    acf_add_local_field_group([
        'key' => 'group_project_details',
        'title' => 'Project Details',
        'fields' => [
            [
                'key' => 'field_client_name',
                'label' => 'Client Name',
                'name' => 'client_name',
                'type' => 'text',
            ],
            [
                'key' => 'field_completion_date',
                'label' => 'Completion Date',
                'name' => 'completion_date',
                'type' => 'date_picker',
            ],
        ],
        'location' => [[['param' => 'post_type', 'operator' => '==', 'value' => 'project']]],
    ]);
}
reading ACF fields in a template
<?php
$client = get_field('client_name');
$completed = get_field('completion_date');
?>
<p>Client: <?= esc_html($client) ?></p>
<p>Completed: <?= esc_html($completed) ?></p>

get_field() reads from wp_postmeta — ACF is essentially a well-built admin UI and query layer over the exact key/value pattern covered in Week 17. esc_html() is WordPress's equivalent of Week 1's htmlspecialchars()/Blade's automatic escaping — always wrap dynamic output in a theme template with it, same rule, same reason.

4. Templating a Custom Post Type

The template hierarchy from last week extends naturally to CPTs — WordPress looks for type-specific files before falling back further:

wp-content/themes/my-theme/single-project.php
<?php get_header(); ?>

<main>
  <?php while (have_posts()) : the_post(); ?>
    <article>
      <h1><?php the_title(); ?></h1>
      <p>Client: <?= esc_html(get_field('client_name')) ?></p>
      <?php the_content(); ?>
    </article>
  <?php endwhile; ?>
</main>

<?php get_footer(); ?>

single-project.php is picked up automatically for any single Project post — archive-project.php follows the same convention for the Projects listing page, both without a single line of routing code anywhere.

5. Template Parts

Markup repeated across multiple templates — a card layout used on both the blog index and a related-posts block, say — belongs in a template part, WordPress's version of a partial/component:

wp-content/themes/my-theme/template-parts/project-card.php
<article class="project-card">
  <?php if (has_post_thumbnail()) : ?>
    <?php the_post_thumbnail('medium'); ?>
  <?php endif; ?>
  <h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
  <p><?= esc_html(get_field('client_name')) ?></p>
</article>
using it, e.g. in archive-project.php
<?php while (have_posts()) : the_post(); ?>
  <?php get_template_part('template-parts/project-card'); ?>
<?php endwhile; ?>

get_template_part() inside The Loop runs with the current post already set up, so the_title(), the_permalink() and get_field() inside the template part automatically refer to the right post — no argument-passing required, unlike a Blade component's explicit props.

6. Hands-on Exercise

Hands-on

Build a Projects portfolio section

A complete Custom Post Type, taxonomy, ACF fields and templates, on top of last week's theme.

Requirements:

  1. Register a project CPT with an archive, featured image support, and show_in_rest enabled.
  2. Register a hierarchical project_type taxonomy attached to it (e.g. "Web", "Mobile", "Design").
  3. Install ACF and add a field group with client_name (text) and completion_date (date) fields, scoped to the project post type.
  4. Build archive-project.php listing every project via a project-card template part, and single-project.php showing full details.
  5. Create at least 6 projects across at least 2 different project_type terms, and confirm the archive page correctly displays all of them with their ACF fields.
Hint

If a newly registered CPT's archive page 404s, visit Settings → Permalinks in the admin and click "Save" once — WordPress needs to regenerate its rewrite rules after a CPT is registered, and this is the fastest way to force that without extra code.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Where does a Custom Post Type's data actually live — a new table, or somewhere existing?

The same wp_posts table used for ordinary posts and pages — register_post_type('project', ...) doesn't create a new database table. It's distinguished purely by its post_type column value, exactly like the post-vs-page distinction from Week 17.

Q2

Why does this lesson recommend registering a CPT in a plugin rather than functions.php for anything beyond a learning exercise?

A CPT registration defines a content type — data, not presentation — and a theme is meant to be swappable without losing content. If "Projects" is only registered in the active theme's functions.php, switching themes (even just to preview a new one) unregisters the post type, and every existing Project post effectively becomes orphaned and inaccessible through the normal admin UI until the type is registered again.

Q3

What does get_field('client_name') from ACF actually read from, under the hood?

wp_postmeta — the same key/value metadata table from Week 17. ACF is a well-designed admin interface and query layer built on top of that existing storage mechanism, not a separate database structure; each ACF field is ultimately stored as a meta_key/meta_value pair tied to the post's ID.

Q4

Why doesn't get_template_part('template-parts/project-card') need the current post passed to it as an argument?

Called from inside an active Loop (after the_post() has run), the global $post — and everything derived from it, including the_title(), the_permalink(), and ACF's get_field() — is already set to the current post. The template part reads from that shared global state rather than receiving data explicitly, unlike a Blade component's props, which is a genuinely different mechanism from Laravel's explicit data-passing.