WordPress Routing for Custom Post Types and Pages

Posted on March 04, 2021 · 5 mins read

I finally solved a WordPress problem that’s bugged me for the past few weeks! MITCNC has a number of pages under the /programs/ path that describes our various initiatives and lists the volunteers in charge of them. Many of these pages are custom templates with hard-coded content, and require a Git commit and deploy to update. We want to get back to doing updates in WordPress Admin so that any of our volunteers can make content changes. My solution to this problem is to implement a custom post type, and use custom fields to provide the data for various page components.

This solution yielded a new problem with routing. The custom post type uses programs as its slug, so all requests to /programs/ are being interpreted as custom post types rather than pages. This means we need to port all the existing pages to custom post types before we can deploy the custom post type. This is not ideal for many reasons, including a lack of time or desire to move all pages in one fell swoop.

I discovered the parse_request hook after reading a few StackOverflow posts last night, and found my routing solution. When a request goes to the custom post type, the callback first checks to see if the custom post exists. If so, the query is returned unchanged. If not, the query is rewritten to find a page at the given path. The callback code is below. Note that $query->request is the URL path (e.g., programs/k-12-steam-initiatives/).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
add_action('parse_request', 'MITCNC_Theme\Functions\ProgramPage\maybe_route_to_page', 10, 1);
/**
 * Routes custom post type requests to pages with the same slug/path prefix if
 * the page does not exist. This allows us to have pages and custom post types
 * with the same slug.
 *
 * This was adapted from https://wordpress.stackexchange.com/a/323249/23157.
 *
 * @param $query \WP
 * @return \WP
 */
function maybe_route_to_page($query): \WP
{
    $custom_post_type_slug = 'programs';
    $page_name = $query->request;

    // We only want to touch requests to the custom post type
    if (
        count($query->query_vars) > 0 &&
        str_starts_with($page_name, $custom_post_type_slug . '/')
    ) {
        // Use the current query if it routes to a published post.
        // Custom post types should take precedence over pages.
        if (has_post($query->query_vars)) {
            return $query;
        }

        // No custom post exists, so route to the page.
        $query->query_vars = array(
            'page' => '',
            'pagename' => $page_name,
        );

        return $query;
    } else {
        return $query;
    }
}


/**
 * Determine if a post exists for the given query.
 *
 * @param $args array Query passed to `get_posts`
 * @return bool
 */
function has_post(array $args): bool
{
    // We just need to determine if a post exists,
    // so don't bother loading more than one post.
    $args['numberposts'] = 1;

    $result = get_posts($args);
    return count($result) > 0;
}

I don’t like that this solution introduces an extra database call to check if the page exists, but I haven’t found a fix. The ’relation’ clause on queries only seems to apply to taxonomy and metadata fields. Fortunately, the database call is only made for requests to the /programs/ path, so the extra call doesn’t drastically affect site speed.