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:
<?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.
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:
<?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:
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:
<?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']]],
]);
}
<?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:
<?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:
<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>
<?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
Build a Projects portfolio section
A complete Custom Post Type, taxonomy, ACF fields and templates, on top of last week's theme.
Requirements:
- Register a
projectCPT with an archive, featured image support, andshow_in_restenabled. - Register a hierarchical
project_typetaxonomy attached to it (e.g. "Web", "Mobile", "Design"). - Install ACF and add a field group with
client_name(text) andcompletion_date(date) fields, scoped to theprojectpost type. - Build
archive-project.phplisting every project via aproject-cardtemplate part, andsingle-project.phpshowing full details. - Create at least 6 projects across at least 2 different
project_typeterms, and confirm the archive page correctly displays all of them with their ACF fields.
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?
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?
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?
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?
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.