Skip to main content

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

classDiagram class Post { +int ID +string post_title +string post_content +string post_status +string post_type +string post_name +int post_parent } Post <|-- BlogPost Post <|-- Page Post <|-- Attachment Post <|-- CustomPostType class BlogPost { post_type = "post" } class Page { post_type = "page" } class Attachment { post_type = "attachment" } class CustomPostType { post_type = "your_type" }
TypeNatureOrganized byReal-world analog
PostTimely, dated, chronologicalCategories & tagsMagazine articles
PageStatic, timeless, hierarchicalParent/child, templatesBook chapters
AttachmentUploaded mediaThe Media LibraryFiling cabinet of assets
Custom post typeWhatever you defineCustom taxonomiesA 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.

ArgumentWhat it controlsTypical value
publicVisible on the front end and in admintrue
hierarchicalParent/child (page-like) or flat (post-like)false
has_archiveWhether an archive page existstrue
supportsEditor features enabled['title','editor','thumbnail']
rewriteThe URL slug['slug' => 'projects']
show_in_restBlock editor + REST API supporttrue

Taxonomies

Taxonomies classify content. WordPress ships two: categories (hierarchical) and tags (flat). You can register your own to organize custom post types.

flowchart TD A[Taxonomies] --> B[Categories] A --> C[Tags] A --> D[Custom Taxonomies] B --> B1[Hierarchical] B --> B2[Broad topics] C --> C1[Non-hierarchical] C --> C2[Specific labels] D --> D1[e.g. Cuisine, Skill, Location]
CategoriesTags
StructureHierarchical (parent/child)Flat
Required?Yes (defaults to "Uncategorized")No
Best forBroad, planned sectionsAd-hoc, specific details
AnalogyBookstore sectionsIndex 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.

erDiagram wp_posts ||--o{ wp_postmeta : has wp_posts { int ID string post_title string post_type } wp_postmeta { int meta_id int post_id string meta_key string meta_value }

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

  1. Decide the post type: should recipes be posts, or a custom recipe type? Justify it.
  2. Choose taxonomies: which should be hierarchical (category-like) and which flat (tag-like)? Consider meal type, cuisine, and dietary restriction.
  3. List the custom fields a recipe needs (prep time, servings, difficulty, etc.) and pick a sanitizer for each.
  4. 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 => true to 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_posts for the main query; reset custom queries with wp_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_query filters on large sites without caching (transients help).

Summary & Quiz

πŸŽ‰ Key Takeaways

  • WordPress uses a unified content model: one wp_posts table, many post_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_posts for the main query.
  • Uploaded files are attachment posts; media_handle_upload() handles secure uploads.

πŸ“š Further Reading

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