ποΈ Content Management Concepts
A CMS earns its name by keeping content separate from presentation. In this lesson you'll learn how WordPress models content β one flexible table behind posts, pages, and anything you invent β and how taxonomies, custom fields, and the query system let you build sophisticated, well-organized sites.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the WordPress unified content model and when to use posts, pages, or custom post types
- Register a custom post type and a custom taxonomy correctly
- Extend content with post meta (custom fields), saved securely with nonces and sanitization
- Retrieve and display content with WP_Query and The Loop
- Modify the main query safely with pre_get_posts and handle media in code
Estimated Time: 50β60 minutes β’ Difficulty: Intermediate
Hands-on: Design the content architecture for a recipe site β post type, taxonomies, and fields.
In This Lesson
WordPress as a CMS
A content management system lets people create and manage content without touching code. WordPress grew from a blogging tool into one of the most capable CMSs on the web precisely because it does five things well:
- Separates content from presentation β writers write; themes decide how it looks.
- Organizes content meaningfully β categories, tags, and custom taxonomies.
- Supports many content types β posts, pages, and unlimited custom post types.
- Controls publishing workflows β drafts, scheduling, revisions, and user roles.
- Reuses content β archives, search, related content, shortcodes, and blocks.
π‘ A useful analogy: WordPress is like a well-run library. Books (posts) are shelved by section (category) and cross-referenced in the index (tags). The library can add a whole new special collection (a custom post type) with its own catalogue rules (a custom taxonomy) whenever a new kind of material arrives.
The Unified Content Model
Nearly all WordPress content lives in one table, wp_posts, differentiated by the post_type column. Posts, pages, attachments, revisions, and your custom types are all "posts" under the hood.
| Type | Nature | Organized by | Real-world analog |
|---|---|---|---|
| Post | Timely, dated, chronological | Categories & tags | Magazine articles |
| Page | Static, timeless, hierarchical | Parent/child, templates | Book chapters |
| Attachment | Uploaded media | The Media Library | Filing cabinet of assets |
| Custom post type | Whatever you define | Custom taxonomies | A specialized filing system |
π Post vs Page β the decision
Use a post when the content is dated and flows in a stream (news, blog entries). Use a page when the content is standalone and stable (About, Contact, Privacy Policy). Reach for a custom post type when the content is neither β products, events, team members β and deserves its own admin section and templates.
Custom Post Types
Custom post types (CPTs) extend WordPress to model content it doesn't ship with. You register them with register_post_type(), hooked to init, usually from a plugin (so the content survives a theme switch).
<?php
add_action( 'init', 'mycb_register_project_cpt' );
function mycb_register_project_cpt(): void {
$labels = [
'name' => _x( 'Projects', 'Post type general name', 'mycb' ),
'singular_name' => _x( 'Project', 'Post type singular name', 'mycb' ),
'menu_name' => _x( 'Projects', 'Admin Menu text', 'mycb' ),
'add_new_item' => __( 'Add New Project', 'mycb' ),
'edit_item' => __( 'Edit Project', 'mycb' ),
'all_items' => __( 'All Projects', 'mycb' ),
'not_found' => __( 'No projects found.', 'mycb' ),
];
$args = [
'labels' => $labels,
'public' => true,
'has_archive' => true,
'hierarchical' => false, // like posts, not pages
'menu_icon' => 'dashicons-portfolio',
'menu_position'=> 5,
'rewrite' => [ 'slug' => 'projects' ],
'supports' => [ 'title', 'editor', 'author', 'thumbnail', 'excerpt' ],
'show_in_rest' => true, // enables the block editor + REST
];
register_post_type( 'project', $args );
}
β οΈ Flush rewrite rules after registering
New CPTs add permalink rules that WordPress only refreshes on demand. After first registering, visit Settings β Permalinks and save once (or call flush_rewrite_rules() on plugin activation β never on init). Skip this and your /projects/ archive returns a 404.
| Argument | What it controls | Typical value |
|---|---|---|
public | Visible on the front end and in admin | true |
hierarchical | Parent/child (page-like) or flat (post-like) | false |
has_archive | Whether an archive page exists | true |
supports | Editor features enabled | ['title','editor','thumbnail'] |
rewrite | The URL slug | ['slug' => 'projects'] |
show_in_rest | Block editor + REST API support | true |
Taxonomies
Taxonomies classify content. WordPress ships two: categories (hierarchical) and tags (flat). You can register your own to organize custom post types.
| Categories | Tags | |
|---|---|---|
| Structure | Hierarchical (parent/child) | Flat |
| Required? | Yes (defaults to "Uncategorized") | No |
| Best for | Broad, planned sections | Ad-hoc, specific details |
| Analogy | Bookstore sections | Index entries |
Registering a custom taxonomy
<?php
add_action( 'init', 'mycb_register_skill_taxonomy' );
function mycb_register_skill_taxonomy(): void {
$labels = [
'name' => _x( 'Skills', 'taxonomy general name', 'mycb' ),
'singular_name' => _x( 'Skill', 'taxonomy singular name', 'mycb' ),
'add_new_item' => __( 'Add New Skill', 'mycb' ),
'menu_name' => __( 'Skills', 'mycb' ),
];
$args = [
'labels' => $labels,
'hierarchical' => false, // false = tag-like, true = category-like
'public' => true,
'show_admin_column' => true, // a handy column in the post list
'rewrite' => [ 'slug' => 'skills' ],
'show_in_rest' => true,
];
// Attach the taxonomy to the 'project' post type.
register_taxonomy( 'skill', [ 'project' ], $args );
}
π‘ Register CPT before taxonomy?
Order within init doesn't strictly matter because both run during the same hook, but registering the post type first keeps intent clear. What does matter: the taxonomy's second argument must name a post type that will exist.
Post Meta & Custom Fields
Post meta stores arbitrary keyβvalue data alongside a post in wp_postmeta. It's how you attach a price to a product, a date to an event, or a client name to a project.
The four meta functions
<?php
add_post_meta( $post_id, '_project_client', 'Acme Co', true ); // add (unique)
$client = get_post_meta( $post_id, '_project_client', true ); // read single value
update_post_meta( $post_id, '_project_client', 'Globex' ); // update (or create)
delete_post_meta( $post_id, '_project_client' ); // delete
π Why the leading underscore?
Prefixing a meta key with _ (like _project_client) hides it from the generic "Custom Fields" box in the editor. Combined with a project-specific prefix, it prevents both accidental edits and collisions with other plugins.
A secure meta box
When you build your own editor UI for meta, three checks are non-negotiable on save: a valid nonce, not an autosave, and the user has permission.
<?php
// 1. Register the meta box.
add_action( 'add_meta_boxes', function (): void {
add_meta_box( 'project_details', 'Project Details', 'mycb_render_box', 'project', 'normal', 'high' );
} );
// 2. Render it (with a nonce).
function mycb_render_box( WP_Post $post ): void {
wp_nonce_field( 'project_details_save', 'project_details_nonce' );
$client = get_post_meta( $post->ID, '_project_client', true );
printf(
'<p><label for="project_client">Client:</label>
<input type="text" id="project_client" name="project_client" value="%s" class="widefat"></p>',
esc_attr( $client )
);
}
// 3. Save it β safely.
add_action( 'save_post_project', function ( int $post_id ): void {
if ( ! isset( $_POST['project_details_nonce'] )
|| ! wp_verify_nonce( $_POST['project_details_nonce'], 'project_details_save' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
if ( isset( $_POST['project_client'] ) ) {
update_post_meta( $post_id, '_project_client', sanitize_text_field( $_POST['project_client'] ) );
}
} );
β In real projects, reach for a field framework
Hand-rolling meta boxes is educational but tedious. Production teams use Advanced Custom Fields (ACF), Meta Box, CMB2, or Carbon Fields to register rich field groups declaratively β then read them with get_field() (ACF) in templates. The security principles above still apply underneath.
WP_Query & The Loop
WP_Query is the engine that fetches content; The Loop is the pattern that displays it. Together they render almost every page on a WordPress site.
A custom query and Loop
<?php
$recent = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'DESC',
] );
if ( $recent->have_posts() ) :
while ( $recent->have_posts() ) : $recent->the_post(); ?>
<article>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<p class="meta"><?php echo esc_html( get_the_date() ); ?> by <?php the_author(); ?></p>
<div class="excerpt"><?php the_excerpt(); ?></div>
</article>
<?php endwhile;
wp_reset_postdata(); // ALWAYS reset after a custom query
else :
echo '<p>No posts found.</p>';
endif;
β οΈ Never forget wp_reset_postdata()
A custom WP_Query overwrites the global $post. If you don't reset it, template tags after your loop (like the page title or comments) will silently pull the wrong post's data.
Filtering by taxonomy and meta
<?php
$featured_projects = new WP_Query( [
'post_type' => 'project',
'tax_query' => [
[ 'taxonomy' => 'skill', 'field' => 'slug', 'terms' => [ 'javascript', 'php' ], 'operator' => 'AND' ],
],
'meta_query' => [
[ 'key' => '_featured', 'value' => '1', 'compare' => '=' ],
],
] );
Modify the main query with pre_get_posts
To change the main query (an archive, the blog page, search) don't build a new WP_Query β adjust the existing one before it runs. This is faster and preserves pagination.
<?php
add_action( 'pre_get_posts', function ( WP_Query $query ): void {
// Guard: only the main, front-end query.
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Show 20 posts on category archives instead of the default.
if ( $query->is_category() ) {
$query->set( 'posts_per_page', 20 );
}
// Include custom post types in search results.
if ( $query->is_search() ) {
$query->set( 'post_type', [ 'post', 'page', 'project' ] );
}
} );
π The Loop's template tags
Inside the Loop, template tags operate on the "current" post: the_title(), the_content(), the_excerpt(), the_permalink(), the_post_thumbnail(), the_author(), and the_category(). Outside the Loop, use their get_* equivalents (e.g. get_the_title( $id )).
Media Management
Every uploaded file becomes an attachment post. The Media Library is your central store; in code, a small set of functions covers most needs.
Featured images and sizes
<?php
// Register custom sizes once (in the theme).
add_action( 'after_setup_theme', function (): void {
add_theme_support( 'post-thumbnails' );
add_image_size( 'project-card', 600, 400, true ); // width, height, hard crop
} );
// In a template:
if ( has_post_thumbnail() ) {
the_post_thumbnail( 'project-card', [ 'class' => 'card-image' ] );
}
// Just the URL:
$url = get_the_post_thumbnail_url( get_the_ID(), 'full' );
Programmatic uploads (front-end forms)
<?php
function mycb_handle_upload(): int|WP_Error {
if ( ! isset( $_POST['upload_nonce'] )
|| ! wp_verify_nonce( $_POST['upload_nonce'], 'mycb_upload' ) ) {
return new WP_Error( 'nonce', 'Security check failed' );
}
if ( empty( $_FILES['my_file'] ) ) {
return new WP_Error( 'no_file', 'No file uploaded' );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
// media_handle_upload validates type, moves the file, and creates the attachment.
return media_handle_upload( 'my_file', 0 );
}
media_handle_upload() does the heavy lifting: it validates the MIME type against WordPress's allow-list, moves the file into uploads/, generates the sized thumbnails, and inserts the attachment post β returning the new attachment ID (or a WP_Error).
Hands-on: Recipe Architecture
ποΈ Design a recipe content model
Objective: Before writing a single template, plan the content architecture for a recipe website. This is the design skill that separates a maintainable site from a tangled one.
Instructions
- Decide the post type: should recipes be posts, or a custom
recipetype? Justify it. - Choose taxonomies: which should be hierarchical (category-like) and which flat (tag-like)? Consider meal type, cuisine, and dietary restriction.
- List the custom fields a recipe needs (prep time, servings, difficulty, etc.) and pick a sanitizer for each.
- Sketch one WP_Query: "quick vegetarian dinners under 30 minutes."
π‘ Hint
Hierarchical taxonomies suit fixed, planned classifications (meal type, cuisine). Flat taxonomies suit open-ended labels (ingredients, dietary tags). Numeric fields like prep time should be sanitized with absint(); free text with sanitize_text_field().
β Example solution
Post type: a custom recipe CPT β recipes deserve their own admin menu, archive, and templates, and shouldn't mingle with the blog.
Taxonomies: meal_type and cuisine hierarchical; ingredient and dietary flat.
<?php
add_action( 'init', function (): void {
register_post_type( 'recipe', [
'labels' => [ 'name' => 'Recipes', 'singular_name' => 'Recipe' ],
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-food',
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
'show_in_rest' => true,
'rewrite' => [ 'slug' => 'recipes' ],
] );
register_taxonomy( 'meal_type', 'recipe', [ 'hierarchical' => true, 'show_in_rest' => true, 'label' => 'Meal Types' ] );
register_taxonomy( 'cuisine', 'recipe', [ 'hierarchical' => true, 'show_in_rest' => true, 'label' => 'Cuisines' ] );
register_taxonomy( 'dietary', 'recipe', [ 'hierarchical' => false, 'show_in_rest' => true, 'label' => 'Dietary' ] );
} );
// "Quick vegetarian dinners under 30 minutes"
$quick = new WP_Query( [
'post_type' => 'recipe',
'tax_query' => [
[ 'taxonomy' => 'meal_type', 'field' => 'slug', 'terms' => 'dinner' ],
[ 'taxonomy' => 'dietary', 'field' => 'slug', 'terms' => 'vegetarian' ],
],
'meta_query' => [
[ 'key' => '_prep_minutes', 'value' => 30, 'type' => 'NUMERIC', 'compare' => '<=' ],
],
] );
π― Quick Quiz
Question 1: Which taxonomy type supports parent/child relationships?
Question 2: After a custom WP_Query loop, what must you call?
Question 3: To change how many posts a category archive shows, the efficient approach is to:
Best Practices
β Do
- Register CPTs and taxonomies in a plugin, so content survives theme changes.
- Set
show_in_rest => trueto enable the block editor and REST for your types. - Prefix meta keys (and use a leading
_) to avoid clashes and hide internal fields. - Prefer
pre_get_postsfor the main query; reset custom queries withwp_reset_postdata().
β οΈ Don't
- Over-engineer: only create the post types and taxonomies you actually need.
- Forget to flush permalinks after adding a CPT (or archives 404).
- Save meta without checking nonce, autosave, and capability.
- Run heavy
meta_queryfilters on large sites without caching (transients help).
Summary & Quiz
π Key Takeaways
- WordPress uses a unified content model: one
wp_poststable, manypost_types. - Posts are timely, pages are static, CPTs model everything else.
- Taxonomies classify content β hierarchical (categories) or flat (tags).
- Post meta extends content with custom fields; save it with nonce + autosave + capability checks.
- WP_Query + The Loop retrieve and display content; use
pre_get_postsfor the main query. - Uploaded files are attachment posts;
media_handle_upload()handles secure uploads.
π Further Reading
- Plugin Handbook β Custom Post Types
- Plugin Handbook β Taxonomies
- WP_Query Class Reference
- Advanced Custom Fields Documentation
π What's Next?
You now know how WordPress structures content. Next we get it running: the installation options and configuration that turn a bare server into a working WordPress site.
π Great progress!
You can design a content model, register types and taxonomies, and query anything WordPress stores.