Two blogs, one WordPress

We recently moved Mary’s website from Movable Type to a self-hosted WordPress install. The site is made up of two blogs and it took a while to figure out how to get WordPress to handle that. This is how I did it.

By default WordPress is a single blog, featuring blog Posts, which can be grouped using Categories and Tags. We wanted to add a second blog to the same WordPress-powered site, with its own, separate, Posts, Categories and Tags.

I saw a few blog posts about how to manage this but none of them seemed like the real deal, relying on faking multiple blogs by using categories. I wanted two “real” separate blogs, but on one site. I thought this would be fairly easy, as I knew creating custom post types is simple. But fulfilling all the requirements was a pain.

They are:

  • A “main Blog” with its own, standard, Posts, Categories and Tags.
  • A separate “Reading” blog, featuring book reviews, with its own, entirely separate, Posts, Categories and Tags (which we’ll call “Reviews”, “Genres” and “Authors”).
  • Separate URL structures for each blog.
  • A home page for each blog featuring the most recent posts on each one, and are both paged.
  • A front page that displays the most recent posts from both blogs combined, and is paged.
  • Separate sections in the WordPress Admin for editing each blog’s Posts.
  • All of this in one site, using one theme.

I eventually pieced all this together from disparate bits of documentation, blog posts and Stack Exchange answers, and thought it worth bringing together here. The full theme is on GitHub, where the code might be more readable, but I’ll include the relevant code as I step through it below.

Our theme is a child theme of the standard WordPress Twenty Sixteen theme. Some of the later steps are specific to this situation but hopefully they’re easy to adapt to yours, if different.

You might find that using directory-based WordPress Multisite is the better solution. I’ve never tried it and ended up here, after initially thinking this would just be a quick hack. This is what I pieced together and it seems to work.

1. Assumptions

You should have your own theme, which might be a child theme. Ours, as mentioned, is a child of Twenty Sixteen theme. Your child theme should have a functions.php file, where we’ll put most of this code.

All the functions and item names are prefixed with sparkly_ in our theme — you can replace all those with whatever’s appropriate to you.

WP’s standard Posts, Categories and Tags will be part of our “main Blog”. We’ll create a separate “Reading” blog with its own Posts (called Reviews), Categories (Genres) and Tags (Authors). You can name your second blog and its entities whatever you like.

We want the URLs for the parts of our “Reading” blog to be like this:

/reading/2017/02/19/post-name/  # A single Review.
/reading/2017/02/19/            # Reviews from one day.
/reading/2017/02/               # Reviews from one month.
/reading/2017/                  # Reviews from one year
/reading/                       # The most recent Reviews.
/reading/genre/genre-name/      # Reviews in a Genre.
/reading/author/author-name/    # Reviews by an Author.

The URLs for the “main Blog” will be similar, but start with /blog/ instead of /reading/.

2. Preparation

Some things we need to do in the WordPress admin first:

  • Create two new, empty, Pages: “Home” and “Blog”.
  • In ‘Settings’ go to the “Reading” section.
    • For “Front page displays”, choose ‘A static page’.
    • Set the front page to be “Home” and the Posts page to be “Blog”.
    • Save changes.
  • In “Settings” go to “Permalinks” and set the link structure you want for the “main Blog”. I used a Custom Structure of /blog/%year%/%monthnum%/%day%/%postname%/. Save changes.

3. Creating the custom post type

The basics of creating a custom post type are documented here. To create our Review post type (known in the code as sparkly_review) we add this to our functions.php:

function sparkly_create_review_post_type() {
    register_post_type(
        'sparkly_review',
        array(
            'public' => true,
            'has_archive' => true,
            'hierarchical' => false,
            'rewrite' => array(
                // So the front page of recent Reviews will be at /reading/
                'slug' => 'reading',
                'with_front' => false,
            ),
            'capability_type' => 'post',
            'menu_position' => 5, // places menu item directly below Posts
            'menu_icon' => 'dashicons-book-alt',
            'labels' => array(
                'name' => __( 'Reviews' ),
                'singular_name' => __( 'Review' )
            ),
            'supports' => array( 'title', 'editor', 'author', 'thumbnail', 'excerpt', 'trackbacks', 'custom-fields', 'comments', 'revisions',),
        )
    );
}
add_action( 'init', 'sparkly_create_review_post_type', 10);

This should create what acts much like a standard WordPress Post, only it’s called a Review. We use the rewrite array to specify that the front page of this new blog will be at /reading/.

While this creates the new Review posts it doesn’t specify the URLs of those posts. Onward…

4. Custom post type URLs

We need to add some mappings to translate from the nice URLs we described earlier into the URLs that WordPress uses internally to display things.

Inside the function we just created in step 3 we add more after the register_post_type() call:

function sparkly_create_review_post_type() {
    // Add the custom post type itself.
    register_post_type(
        // AS IN THE EXAMPLE ABOVE.
    );

    // Add the permalink structure we want:
    add_permastruct(
        'sparkly_review',
        '/reading/%year%/%monthnum%/%day%/%name%/',
        false
    );

    // Matching nice URLs to the URLs WP will use to get review(s)...

    // All reviews from one day:
    add_rewrite_rule(
        '^reading/([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})(/page/([0-9]+))?/?$',
        'index.php?post_type=sparkly_review&year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&paged=$matches[5]',
        'top'
    );
    // An individual review:
    add_rewrite_rule(
        '^reading/([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})/([^/]+)/?$',
        'index.php?post_type=sparkly_review&name=$matches[4]',
        'top'
    );
    // All reviews from one month:
    add_rewrite_rule(
        '^reading/([0-9]{4})/([0-9]{1,2})(/page/([0-9]+))?/?$',
        'index.php?post_type=sparkly_review&year=$matches[1]&monthnum=$matches[2]&paged=$matches[4]',
        'top'
    );
    // All reviews from one year:
    add_rewrite_rule(
        '^reading/([0-9]{4})(/page/([0-9]+))?/?$',
        'index.php?post_type=sparkly_review&year=$matches[1]&paged=$matches[3]',
        'top'
    );
}
add_action( 'init', 'sparkly_create_review_post_type', 10);

The add_permastruct() describes the URL structure, like we would in WordPress’s Permalinks settings. And the add_rewrite_rules() maps from different URLs to the internal WordPress URL that it uses to find the correct posts, templates, etc to display.

NOTE: After changing anything to do with the rewrite rules, you’ll need to flush the rules WordPress has stored. Do this by going to “Settings” and “Permalinks” and clicking “Save Changes”.

We also need to add another function:

function sparkly_review_permalinks( $url, $post ) {
    if ( 'sparkly_review' == get_post_type( $post ) ) {
        $url = str_replace( "%year%", get_the_date('Y'), $url );
        $url = str_replace( "%monthnum%", get_the_date('m'), $url );
        $url = str_replace( "%day%", get_the_date('d'), $url );
        $url = str_replace( "%name%", $post->post_name, $url );
    }
    return $url;
}
add_filter( 'post_type_link', 'sparkly_review_permalinks', 10, 2 );

I think this works with the add_permalinks() call we added and means that when WordPress wants to generate the URL of a Review post, it makes it using our nice permalink structure.

Thanks to “Milo” on the WordPress Stack Exchange for help with the URLs.

5. Custom categories

Our Reviews are going to be categorised by genre. We don’t want to use the standard Categories — which are used by the “main Blog” — so we create a new hierarchical taxonomy:

function sparkly_genres_init() {
    $labels = array(
        'name' => _x( 'Genres', 'taxonomy general name' ),
        'singular_name' => _x( 'Genre', 'taxonomy singular name' ),
        'search_items' =>  __( 'Search Genres' ),
        'all_items' => __( 'All Genres' ),
        'parent_item' => __( 'Parent Genre' ),
        'parent_item_colon' => __( 'Parent Genre:' ),
        'edit_item' => __( 'Edit Genre' ), 
        'update_item' => __( 'Update Genre' ),
        'add_new_item' => __( 'Add New Genre' ),
        'new_item_name' => __( 'New Genre Name' ),
        'menu_name' => __( 'Genres' ),
    );  
    register_taxonomy(
        'sparkly_genres', // The taxonomy name.
        'sparkly_review', // Associate it with our custom post type.
        array(
            'hierarchical' => true,
            'labels' => $labels,
            'show_ui' => true,
            'show_admin_column' => true,
            'query_var' => true,
            'rewrite' => array(
                // So Reviews in one Genre will be found at
                // /reading/genre/science-fiction/
                'slug' => 'reading/genre',
                'with_front' => false
            ),
        )
    );
}
add_action( 'init', 'sparkly_genres_init', 0 );

This creates the new taxonomy, known in the code as sparkly_genres, and associates it with the Review custom post type we created first.

We specify that this is hierarchical, like the standard Categories.

We also specify what the start of the genres’ URLs will be using the rewrite array.

6. Custom tags

We also want to tag each Review with the names of authors. We’ll do this with a new taxonomy that will behave like standard Tags but be entirely separate:

function sparkly_authors_init() {
    $labels = array(
        'name' => _x( 'Authors', 'taxonomy general name' ),
        'singular_name' => _x( 'Author', 'taxonomy singular name' ),
        'search_items' =>  __( 'Search Authors' ),
        'popular_items' => __( 'Popular Authors' ),
        'all_items' => __( 'All Authors' ),
        'parent_item' => null,
        'parent_item_colon' => null,
        'edit_item' => __( 'Edit Author' ), 
        'update_item' => __( 'Update Author' ),
        'add_new_item' => __( 'Add New Author' ),
        'new_item_name' => __( 'New Author Name' ),
        'separate_items_with_commas' => __( 'Separate authors with commas' ),
        'add_or_remove_items' => __( 'Add or remove authors' ),
        'choose_from_most_used' => __( 'Choose from the most used authors' ),
        'menu_name' => __( 'Authors' ),
        'menu_icon' => 'dashicons-admin-users',
    );
    register_taxonomy(
        'sparkly_authors', // The taxonomy name.
        'sparkly_review',  // Associate it with our custom post type.
        array(
            'hierarchical' => false,
            'labels' => $labels,
            'show_ui' => true,
            'show_tagcloud' => true,
            'show_admin_column' => true,
            'update_count_callback' => '_update_post_term_count',
            'query_var' => true,
            'rewrite' => array(
                // So Reviews by an Author will be found at
                // /reading/author/author-slug/
                'slug' => 'reading/author',
                'with_front' => false
            ),
            'capabilities' => array()
        )
    );
}
add_action( 'init', 'sparkly_authors_init' );

This creates the new Tag-like taxonomy, called Authors (or sparkly_authors in the code), and associates it with our Review custom post type.

Again, we use the rewrite array to specify what the start of its URLs will be like.

7. Tweaking the admin

There are a couple of things we need to add to make the admin experience nicer.

The default icons used for our Reviews and Authors, as shown in the WP admin sidebar, didn’t quite fit. So when we created them you may have noticed we specified menu_icons:

        'menu_icon' => 'dashicons-book-alt',

And:

        'menu_icon' => 'dashicons-admin-users',

To find a new icon, look at the Dashicons and select an icon to have it appear at the top of the page, along with its name.

We also wanted the number of Reviews to appear in the Dashboard’s “At a Glance” widget, alongside Posts, Pages and Comments:

Screenshot of the WordPress Dashboard

To do that add this to functions.php:

function add_custom_post_counts() {
    // array of custom post types to add to 'At A Glance' widget
    $post_types = array('sparkly_review');
    foreach ($post_types as $pt) {
        $pt_info = get_post_type_object($pt); // get a specific CPT's details
        $num_posts = wp_count_posts($pt); // retrieve number of posts associated with this CPT
        $num = number_format_i18n($num_posts->publish); // number of published posts for this CPT
        $text = _n( $pt_info->labels->singular_name, $pt_info->labels->name, intval($num_posts->publish) ); // singular/plural text label for CPT
        echo '<li class="'.$pt_info->name.'-count"><a href="edit.php?post_type='.$pt.'">'.$num.' '.$text.'</a></li>';
    }
}
add_action('dashboard_glance_items', 'add_custom_post_counts');

No, that’s not as simple as I imagined it would be. And that’s not all. We also need to specify the icon to use for our Reviews, which requires another function:

function sparkly_admin_head() {
  echo '<style>
    #dashboard_right_now .sparkly_review-count a:before {
        content: "\f331";
    }
  </style>';
}
add_action('admin_head', 'sparkly_admin_head');

This adds some CSS to our admin pages that puts the icon in before our new count of Reviews.

This time, rather than use the name of one of the Dashicons we use its “f” number, as listed on that page, preceded by a \\.

8. The front page

Remember, we want a front page that combines recent Posts, from the “main Blog”, and Reviews. Exactly how you do this depends a bit on your theme, or your parent theme. This is how we did it.

We need to create a front-page.php to combine the two post types. We copied Twenty Sixteen’s index.php into our child theme, renamed it front-page.php, and customised it. The template is on GitHub but, briefly, we added this before get_header(); ?>:

$paged = (get_query_var('page')) ? get_query_var('page') : 1;

// Get both Posts and Reviews:
$args = array(
    'post_type' => array('post', 'sparkly_review'),
    'orderby' => 'date',
    'order' => 'DESC',
    'posts_per_page' => 5,
    'paged' => $paged
);
$the_query = new WP_Query( $args );

// Pagination fix - to make the pagination appear.
$temp_query = $wp_query;
$wp_query   = NULL;
$wp_query   = $the_query;

And then we changed all mentions of:

  • have_posts()
  • is_home()
  • is_front_page()
  • the_post()

to:

  • $the_query->have_posts()
  • $the_query->is_home()
  • $the_query->is_front_page()
  • $the_query->the_post()

Finally, after the call to the_posts_pagination() we had to add:

wp_reset_postdata();

in order to make the pagination appear. I’ve no idea.

9. Blog home page

We wanted the /blog/ page, the home page of the “main Blog”, to look the same as /reading/, the home page of the new “Reading” blog. To do this we added an index.php into our child theme (on GitHub) that contains only this:

<?php
include( get_template_directory() . '/archive.php' );
?>

That ensures we use the same template, archive.php, for the “main Blog” as is used for the Reading blog home page.

We had to add something to have the title on both blog home pages appear exactly how we want though. This is in our functions.php:

function sparkly_modify_archive_title( $title ) {
    if ($title == 'Archives: Reviews') {
        return 'Reading';
    } elseif ($title == 'Archives') {
        return 'Blog';
    } else {
        return $title;
    }
}
add_filter( 'get_the_archive_title', 'sparkly_modify_archive_title', 10, 1 );

This just changes the page titles to be “Blog” or “Reading”.

10. Other tidying up

There were a few other little tweaks we had to make to get things just right.

First, back in functions.php we overrode the parent theme’s twentysixteen_entry_meta() method (in twentysixteen/inc/template-tags.php) so that it worked for our new Review post type as well as standard Posts. You can see this on GitHub. In brief, it meant copying the method into our functions.php and then wherever it had something like:

if ( in_array( get_post_type(), array( 'post' ) ) )

adding 'sparkly_review' to it:

if ( in_array( get_post_type(), array( 'post', 'sparkly_review' ) ) )

Second, and similarly, we had to override the parent theme’s twentysixteen_entry_taxonomies() method so that it would use our two new taxonomies instead of Categories and Tags. This is on GitHub.

In our version, in our functions.php, we replaced:

    $categories_list = get_the_category_list( _x( ', ', 'Used between list items, there is a space after the comma.', 'twentysixteen' ) );

with:

    if ( 'sparkly_review' === get_post_type() ) {
        $categories_list = get_the_term_list(get_the_id(), 'sparkly_genres',  '', ', ');
    } else {
        $categories_list = get_the_category_list( _x( ', ', 'Used between list items, there is a space after the comma.', 'twentysixteen' ) );
    }

And replaced:

    $tags_list = get_the_tag_list( '', _x( ', ', 'Used between list items, there is a space after the comma.', 'twentysixteen' ) );

with:

    if ( 'sparkly_review' == get_post_type() ) {
        $tags_list = get_the_term_list(get_the_id(), 'sparkly_authors',  '', ', ');
    } else {
        $tags_list = get_the_tag_list( '', _x( ', ', 'Used between list items, there is a space after the comma.', 'twentysixteen' ) );
    }

Third, the next/previous links at the bottom of individual posts didn’t appear for Reviews. To get that working we had to copy the single.php file from the parent theme into our child theme. And then change this line:

    } elseif ( is_singular( 'post' ) or ( is_singular( 'sparkly_review' )) ) {

To this:

    } elseif ( is_singular( 'post' ) or ( is_singular( 'sparkly_review' )) ) {

A bit of a pain copying the whole file for that one tweak, but there we go.

Fourth and, yes, finally, we found that the footer that appears below posts in the search results didn’t work correctly for our new Reviews. So we had to copy the twentysixteen/template-parts/content-search.php file into our own child theme’s template-parts/content-search.php and then change this single line:

    <?php if ( in_array(get_post_type(), array('post')) ) : ?>

To this:

    <?php if ( in_array(get_post_type(), array('post', 'sparkly_review')) ) : ?>

11. Menus

You’ll probably want a menu on your site that links to the front page and to each of the two blogs’ home pages.

Using WordPress’s Menus, you can add the “Home” Page and the “Blog” Page (that we created way back at the start).

To link to the Reading blog’s home page I added a Custom Link with the URL of /reading/.

12. Widgets

If you want to use the standard widgets to list your new posts and taxonomies you’re out of luck, as they only work with the standard ones. However, I found the Custom Post Type Widgets plugin works really well as an alternative — standard-looking widgets but for any and all post types and taxonomies.

Summary

That was more complicated than I expected.

It feels like wanting to do this is going against the grain of WordPress, despite “custom post types” existing. Although the name suggests creating a new post type will create a parallel to a standard Post there’s a lot more to it if you want to create an entirely separate blog.

Commenting is disabled on posts once they’re 30 days old.

14 Mar 2017 at Twitter

  • 5:16pm: @gwire To be honest I’ve never used Multisite and I was put off by thinking that this was one “site” and I wanted it to be simple. Hm.
  • 5:14pm: @gwire Because it felt like this should be possible. I’m terrible for setting myself a challenge and not letting go.
  • 5:08pm: Q: How do you make two blogs on one WordPress install?
    A: Laboriously.
    gyford.com/phil/writing/2…
  • 4:10pm: @jkottke Happy Blog Birthday!
  • 10:40am: @dracos I’m sure the BBC’s renowned agility will ensure there’s an amazing alternative quickly in place!
  • 10:37am: @dracos I can’t even parse the “A JSON service will be kept in place…” sentence.
  • 10:26am: @dracos Is there a link to the news? My googling failed.

Music listened to most that week

  1. Mammal Hands (7)
  2. Sports (6)
  3. Quasi (2)
  4. Pixies (2)
  5. Azealia Banks (2)
  6. De La Soul (2)
  7. Elastica (2)
  8. Art Brut (2)
  9. The Delgados (2)
  10. Burial (2)

More at Last.fm...