Category Archives: PHP

Remove post type slug in custom post type URL and move subpages to website root in WordPress

I recently had a need to rewrite the URLs of all parent and child pages in a custom post type so they appeared to live at the website root, but in reality continued to live in a custom post type with their hierarchy.

Preface

The situation:

  1. I have a page called Services that lives at domain.com/services.
  2. I have a custom post type called Services.
  3. I have Services post called Programming that lives at domain.com/services/programming.
  4. I have Services post called Web Development, that is a child of Programming, that lives at domain.com/services/programming/web-development.

The goal:

  1. The Services page should remain where it is i.e. domain.com/services.
  2. The Programming post should appear to live at the website root i.e. domain.com/programming.
  3. The Web Development service should also live at the website root i.e. domain.com/web-development.

The reason:

  1. Shorter URLs.
  2. Keep all website pages together.
  3. Keep all services together.
  4. Maintain hierarchy of services.

Setup

I have a custom post type setup like so:

1
2
3
4
5
6
7
8
9
10
11
function register_services() {
  $arguments = array(
    'label' => 'Services',
    'public' => true,
    'hierarchical' => true,
    'supports' => array('title', 'editor', 'page-attributes'),
    'has_archive' => false,
    'rewrite' => true
  );
  register_post_type('services', $arguments);
}
function register_services() {
	$arguments = array(
		'label' => 'Services',
		'public' => true,
		'hierarchical' => true,
		'supports' => array('title', 'editor', 'page-attributes'),
		'has_archive' => false,
		'rewrite' => true
	);
	register_post_type('services', $arguments);
}

Solution

1. Remove “services” from the custom post type URL

We’re going to use the post_type_link filter to do this.

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
add_filter(
  'post_type_link',
  'custom_post_type_link',
  10,
  3
);
 
function custom_post_type_link($permalink, $post, $leavename) {
 
  $url_components = parse_url($permalink);
  $post_path = $url_components['path'];
  $post_name = end(explode('/', trim($post_path, '/')));
 
  if(!empty($post_name)) {
 
    switch($post->post_type) {
 
      case 'services':
        $permalink = str_replace($post_path, '/' . $post_name . '/', $permalink);
        break;
 
    }
 
  }
 
  return $permalink;
}
add_filter(
	'post_type_link',
	'custom_post_type_link',
	10,
	3
);

function custom_post_type_link($permalink, $post, $leavename) {

	$url_components = parse_url($permalink);
	$post_path = $url_components['path'];
	$post_name = end(explode('/', trim($post_path, '/')));

	if(!empty($post_name)) {

		switch($post->post_type) {

			case 'services':
				$permalink = str_replace($post_path, '/' . $post_name . '/', $permalink);
				break;

		}

	}

	return $permalink;
}
  • Line 10: Let’s split the permalink into scheme (http), host (domain.com) and path (/programming/web-development/), of which we’re interested in the path.
  • Line 11: So we’re going to grab the path.
  • Line 12: We’ll get rid of the beginning and trialing slash, split the path into pieces based on any slashes in the middle, and then grab the last piece of the path, which is now going to be our page name.
  • Line 18-19: If the post type is called services, then we’ll remove the entire path and replace it with just the page name.

All of this will essentially give us URLs like this:

  • domain.com/programming/
  • domain.com/web-development/

But neither of those will now work – you’ll get a 404 error – because WordPress can no longer identify those posts as Services posts and therefore attempts to find pages called Programming and Web Development, which don’t exist, hence the 404 error.

If you take a look at WordPress’ rewrite rules:

1
echo '<pre>' . print_r(get_option('rewrite_rules'), true) . '</pre>';
echo '<pre>' . print_r(get_option('rewrite_rules'), true) . '</pre>';

You’ll see something similar to this:

1
2
3
4
5
Array
(
  [services/(.+?)(/[0-9]+)?/?$] => index.php?services=$matches[1]&page=$matches[2]
  [(.?.+?)(/[0-9]+)?/?$] => index.php?pagename=$matches[1]&page=$matches[2]
)
Array
(
	[services/(.+?)(/[0-9]+)?/?$] => index.php?services=$matches[1]&page=$matches[2]
	[(.?.+?)(/[0-9]+)?/?$] => index.php?pagename=$matches[1]&page=$matches[2]
)

As you can see, using the services slug, WordPress knows to take whatever comes after it ((.+?)) and pass it in as the pagename value ($matches[1]), which translates to something like:

domain.com/index.php?services=programming

Since the first rewrite rule no longer applies (slug removed), it now passes programming in as the pagename without specifying the post type, and since this page doesn’t exist, WordPress can’t return the page.

2. Add post type back to the post query

Now that WordPress doesn’t know that it’s dealing with a custom post type, we need to provide it with this information. For that we’re going to use the pre_get_posts filter.

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
add_filter(
  'pre_get_posts',
  'custom_pre_get_posts'
);
 
function custom_pre_get_posts($query) {
    global $wpdb;
 
    if(!$query->is_main_query()) {
      return;
    }
 
    $post_name = $query->get('pagename');
 
    $post_type = $wpdb->get_var(
      $wpdb->prepare(
        'SELECT post_type FROM ' . $wpdb->posts . ' WHERE post_name = %s LIMIT 1',
        $post_name
      )
    );
 
    switch($post_type) {
      case 'services':
        $query->set('services', $post_name);
        $query->set('post_type', $post_type);
        $query->is_single = true;
        $query->is_page = false;
        break;
    }
 
    return $query;
}
add_filter(
	'pre_get_posts',
	'custom_pre_get_posts'
);

function custom_pre_get_posts($query) {
    global $wpdb;

    if(!$query->is_main_query()) {
	    return;
    }

    $post_name = $query->get('pagename');

    $post_type = $wpdb->get_var(
    	$wpdb->prepare(
    		'SELECT post_type FROM ' . $wpdb->posts . ' WHERE post_name = %s LIMIT 1',
    		$post_name
    	)
    );

    switch($post_type) {
	    case 'services':
	    	$query->set('services', $post_name);
	    	$query->set('post_type', $post_type);
	    	$query->is_single = true;
	    	$query->is_page = false;
	    	break;
    }

    return $query;
}
  • Line 7: We’ll need the WordPress database object to get additional information about the post.
  • Line 9-11: We’re going to make sure that the current query is the main query. Since a page can have multiple queries, we’re limiting our filter to only the query that gets information about the page itself.
  • Line 13: We’re going to grab the pagename from the query, which might be programming or web-development.
  • Line 15-20: Now we’re going to use the pagename to look in the database for that particular post and extract its post type.
  • Line 22-23: If this post has a post type that we know we need to manipulate, we’ll need to perform additional actions.
  • Line 24: We need to a create a query var for the post type and set it to the pagename, so WordPress knows where to actually find the data for the post.
  • Line 25: We also need to set the post_type back to services.
  • Line 26: In order for WordPress to load the right template i.e. single-services.php, we need to set is_single to true.
  • Line 27: Last, but not least, we set is_page to false for good measure, since it’s a custom post type.

Result

You’ve met all your goals. In addition, if you try to create a page with the same name as one of your services, you’ll notice that the slug (i.e. post_name, pagename, etc.) will now increment, avoiding a possible collision.

Here are a couple of things to keep in mind:

  1. I didn’t test the performance of this yet, but if you use some kind of caching system on your website, you should be fine.
  2. All my functions live within a PHP namespace, so I only prefixed them with custom_ for simplicity of this post.
  3. I’ve looked at many different solutions online, and they all had their fair share of problems, so this may too, I just don’t know it yet :)

If you have questions or suggestions, leave them in the comments below.

-RS

Prevent checked categories in meta box from appearing at top in WordPress

I previously shared a jQuery snippet that enforces parent checkboxes to be checked when a child is checked and children to be unchecked if the parent is unchecked.

This works really great in theory, except that WordPress, by default, moves all checked checkboxes to the top of the category list, which effectively breaks the JavaScript snippet because it depends on the hierarchy.

If enforcing the checkboxes is more important to you than how they are visually displayed, here’s a WordPress filter that lets you prevent checked checkboxes from moving to the top.

1
2
3
4
5
6
7
8
9
add_filter(
  'wp_terms_checklist_args',
  NS . 'wp_terms_checklist_args'
);
 
function wp_terms_checklist_args($args) {
  $args['checked_ontop'] = false;
  return $args;
}
add_filter(
	'wp_terms_checklist_args',
	NS . 'wp_terms_checklist_args'
);

function wp_terms_checklist_args($args) {
	$args['checked_ontop'] = false;
	return $args;
}

The constant NS is what I use to maintain namespaces in the plugins I write. This ensures compatibility without having to write crazy long function names or create a class just for the sake of namespacing.

-RS

MySQL query for getting three most recent posts for last 12 months in WordPress

I recently had a need to retrieve the last three posts for the last 12 months that had at least one or more posts in WordPress. Another requirement was to display a “more” link that would redirect the user to the archive of that month, provided there was at least one additional post to show for.

Before we go over how we can do that, here is an excerpt of what the array ultimately will look like:

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
Array
(
    [201303] => Array
        (
            [month_year] => March 2013
            [more_posts] => 1
            [posts] => Array
                (
                    [0] => Array
                        (
                            [post_title] => Test Post 1
                        )
 
                    [1] => Array
                        (
                            [post_title] => Test Post 2
                        )
 
                    [2] => Array
                        (
                            [post_title] => Test Post 3
                        )
 
                )
 
        )
 
    [201302] => Array
        (
            [month_year] => February 2013
            [more_posts] => 1
            [posts] => Array
                (
                    [4] => Array
                        (
                            [post_title] => Test Post 4
                        )
 
                    [5] => Array
                        (
                            [post_title] => Test Post 5
                        )
 
                    [6] => Array
                        (
                            [post_title] => Test Post 6
                        )
 
                )
 
        )
    ...
)
Array
(
    [201303] => Array
        (
            [month_year] => March 2013
            [more_posts] => 1
            [posts] => Array
                (
                    [0] => Array
                        (
                            [post_title] => Test Post 1
                        )

                    [1] => Array
                        (
                            [post_title] => Test Post 2
                        )

                    [2] => Array
                        (
                            [post_title] => Test Post 3
                        )

                )

        )

    [201302] => Array
        (
            [month_year] => February 2013
            [more_posts] => 1
            [posts] => Array
                (
                    [4] => Array
                        (
                            [post_title] => Test Post 4
                        )

                    [5] => Array
                        (
                            [post_title] => Test Post 5
                        )

                    [6] => Array
                        (
                            [post_title] => Test Post 6
                        )

                )

        )
    ...
)

It’s an array with 12 elements, if there are, in fact, 12 months with posts, where each element contains the name of the month with a corresponding year, a boolean of whether there are more posts to show for, and another array with three post titles linking to the full post.

1. Declaring required PHP variables

We need to declare a few PHP variables to get started:

  1. The WordPress database object $wpdb
  2. An array $blog_posts to store all of our results
  3. A variable $num_months for how many months we want
  4. A variable $num_posts for how many posts we want per month
  5. A variable $num_posts_buffer for how many posts per month we actually need (to see if we have more posts)
1
2
3
4
5
global $wpdb;
$blog_posts = array();
$num_months = 12;
$num_posts = 3;
$num_posts_buffer = $num_posts + 1;
global $wpdb;
$blog_posts = array();
$num_months = 12;
$num_posts = 3;
$num_posts_buffer = $num_posts + 1;

2. Declaring required MySQL variables

We also need declare two MySQL variables:

  1. A variable @row_num to track the row number per month
  2. A variable @month_year to track the current month

I’ll share more details on why we need those variables in section 3.2.

1
$wpdb->query('set @row_num := 0, @month_year := \'\'');
$wpdb->query('set @row_num := 0, @month_year := \'\'');

3. Building the MySQL query

This is the meat and potatoes of the post, so we’ll spend a little more time on this. Let’s look at the full query first, then we’ll dissect it.

The MySQL query in PHP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$query = 'SELECT wpp3.ID, wpp3.post_title, wpp3.month_year, wpp3.month_year_slug ';
$query .= 'FROM (SELECT wpp2.*, ';
  $query .= '@row_num := IF(@month_year = month_year, @row_num + 1, 1) AS row_num, ';
  $query .= '@month_year := month_year ';
  $query .= 'FROM (SELECT wpp1.*, ';
    $query .= 'DATE_FORMAT(post_date, \'%%M %%Y\') AS month_year, ';
    $query .= 'DATE_FORMAT(post_date, \'%%Y%%m\') AS month_year_slug ';
    $query .= 'FROM ' . $wpdb->posts . ' AS wpp1 ';
    $query .= 'WHERE wpp1.post_status = %s ';
    $query .= 'AND wpp1.post_type = %s ';
    $query .= 'ORDER BY wpp1.post_date DESC';
    $query .= ') as wpp2';
  $query .= ') as wpp3 ';
$query .= 'WHERE row_num <= %d ';
$query .= 'LIMIT %d';
$query = 'SELECT wpp3.ID, wpp3.post_title, wpp3.month_year, wpp3.month_year_slug ';
$query .= 'FROM (SELECT wpp2.*, ';
	$query .= '@row_num := IF(@month_year = month_year, @row_num + 1, 1) AS row_num, ';
	$query .= '@month_year := month_year ';
	$query .= 'FROM (SELECT wpp1.*, ';
		$query .= 'DATE_FORMAT(post_date, \'%%M %%Y\') AS month_year, ';
		$query .= 'DATE_FORMAT(post_date, \'%%Y%%m\') AS month_year_slug ';
		$query .= 'FROM ' . $wpdb->posts . ' AS wpp1 ';
		$query .= 'WHERE wpp1.post_status = %s ';
		$query .= 'AND wpp1.post_type = %s ';
		$query .= 'ORDER BY wpp1.post_date DESC';
		$query .= ') as wpp2';
	$query .= ') as wpp3 ';
$query .= 'WHERE row_num <= %d ';
$query .= 'LIMIT %d';

The actual MySQL query we’re making once processed by PHP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
SELECT wpp3.id,
  wpp3.post_title,
  wpp3.month_year,
  wpp3.month_year_slug
FROM (
  SELECT wpp2.*,
    @row_num := IF(@month_year = month_year, @row_num + 1, 1) AS row_num,
    @month_year := month_year
  FROM (
    SELECT wpp1.*,
      Date_format(post_date, '%M %Y') AS month_year,
      Date_format(post_date, '%Y%m') AS month_year_slug
    FROM wp_posts AS wpp1
    WHERE wpp1.post_status = 'publish'
      AND wpp1.post_type = 'post'
    ORDER BY wpp1.post_date DESC
  ) AS wpp2
) AS wpp3
WHERE row_num <= 4
LIMIT 48;
SELECT wpp3.id,
  wpp3.post_title,
  wpp3.month_year,
  wpp3.month_year_slug
FROM (
  SELECT wpp2.*,
    @row_num := IF(@month_year = month_year, @row_num + 1, 1) AS row_num,
    @month_year := month_year
  FROM (
    SELECT wpp1.*,
      Date_format(post_date, '%M %Y') AS month_year,
      Date_format(post_date, '%Y%m') AS month_year_slug
    FROM wp_posts AS wpp1
    WHERE wpp1.post_status = 'publish'
      AND wpp1.post_type = 'post'
    ORDER BY wpp1.post_date DESC
  ) AS wpp2
) AS wpp3
WHERE row_num <= 4
LIMIT 48;

3.1 Retrieving most recent published posts

The first subquery retrieves all published posts with all columns, including a new column for the month/year (for display purposes) and another new column for the month/year slug (for indexing purposes). All posts are sorted by most recent first.

  1. Give me all columns
  2. Including the post_date formatted as %M %Y (March 2013) in a new column called month_year
  3. Including the post_date formatted as %Y%m (201303) in a new column called month_year_slug
  4. From the table called wp_posts, referenced as wpp1 from here on out
  5. Where the post_status is equal to publish (not draft, pending, etc)
  6. And the post_type is of type post (not page, revision, etc)
  7. Then order everything by post_date DESC i.e. most recent first

The reason I prefix all my columns is to avoid conflicts when dealing with multiple tables that have the same column name, because MySQL then won’t know which column you’re referring to, so I just got into the habit of doing it. wpp1 in this case is an abbreviated version of wp_posts and the 1 refers to the first instance of that able, since we’ll have more later.

1
2
3
4
5
6
7
SELECT wpp1.*,
      Date_format(post_date, '%M %Y') AS month_year,
      Date_format(post_date, '%Y%m') AS month_year_slug
FROM wp_posts AS wpp1
WHERE wpp1.post_status = 'publish'
      AND wpp1.post_type = 'post'
ORDER BY wpp1.post_date DESC
SELECT wpp1.*,
      Date_format(post_date, '%M %Y') AS month_year,
      Date_format(post_date, '%Y%m') AS month_year_slug
FROM wp_posts AS wpp1
WHERE wpp1.post_status = 'publish'
      AND wpp1.post_type = 'post'
ORDER BY wpp1.post_date DESC

3.2 Retrieving most recent published posts with row number

The purpose of this subquery is to add a column with a set of row numbers for each month/year combination. The reason we’re doing this is so that we can limit the amount of posts per month/year combination later.

  1. Give me all columns from the previous subquery
  2. Including a row_num column containing the row number
    1. If the @month_year variable contains the same month that was set in the last table row (if at all i.e. first month), then take the value of row_num and increment it by one
    2. But if we’re in new month/year combination, then we need to start the row_num at 1
  3. From the table instance we created in the previous subquery
1
2
3
4
5
6
SELECT wpp2.*,
  @row_num := IF(@month_year = month_year, @row_num + 1, 1) AS row_num,
  @month_year := month_year
FROM (
  # Query from section 3.1 here
) AS wpp2
SELECT wpp2.*,
  @row_num := IF(@month_year = month_year, @row_num + 1, 1) AS row_num,
  @month_year := month_year
FROM (
  # Query from section 3.1 here
) AS wpp2

3.3 Retrieving four most recent published posts

In this final part we’re limiting our result set to only what we’re interested in.

  1. Give me the post ID
  2. The post title
  3. The month/year as e.g. March 2013
  4. The month/year slug as e.g. 201303
  5. From the table instance we created in the previous subquery
  6. Where the row number row_num is less than or equal to 4 i.e. rows 1, 2, 3 and 4
  7. But no more than a total of 48 posts (4 posts x 12 months)
1
2
3
4
5
6
7
8
9
SELECT wpp3.id,
  wpp3.post_title,
  wpp3.month_year,
  wpp3.month_year_slug
FROM (
  # Query from section 3.2 here
) AS wpp3
WHERE row_num <= 4
LIMIT 48;
SELECT wpp3.id,
  wpp3.post_title,
  wpp3.month_year,
  wpp3.month_year_slug
FROM (
  # Query from section 3.2 here
) AS wpp3
WHERE row_num <= 4
LIMIT 48;

4. Execute the MySQL query

Now we’re going to submit the query to MySQL and capture the results as an array. This query is prepared because the values could potentially come from outside of our scope via a function parameter — it’s also best practice.

1
2
3
4
5
6
7
$posts = $wpdb->get_results(
  $wpdb->prepare(
    $query,
    'publish', 'post', $num_posts_buffer, ($num_months*$num_posts_buffer)
  ),
  ARRAY_A
);
$posts = $wpdb->get_results(
	$wpdb->prepare(
		$query,
		'publish', 'post', $num_posts_buffer, ($num_months*$num_posts_buffer)
	),
	ARRAY_A
);

5. Looping and organizing through the results

This part is pretty self-explanatory and now that you have the results, you can organize it in any way you want. Here is what I did, with explanations inline.

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// If there are blog posts to loop through
if(!empty($posts)) {
 
  // Start our counter at zero
  $index = 0;
 
  // Create a variable to keep track of whether we need to continue looping
  $continue_looping = true;
 
  // If we need to continue looping and the current post exists
  while($continue_looping && isset($posts[$index])) {
 
    // Store the current post
    $post = $posts[$index];
 
    // Assume there are no more posts in this month
    $more_posts = false;
 
    // If this particular month/year doesn't exist
    if(!isset($blog_posts[$post['month_year_slug']])) {
 
      // We know this is the first post in the month
      $num_posts_month = 1;
 
      // If we have posts for all $num_months, we know we're done
      if(count($blog_posts) >= $num_months) {
 
        // Remember that we're done looping now
        $continue_looping = false;
      } else {
 
        // Create a new month in our array and save the month/year
        $blog_posts[$post['month_year_slug']]['month_year'] = 
        $post['month_year'];
 
        // Assume and save that there are no additional posts
        // in the database for that month
        $blog_posts[$post['month_year_slug']]['more_posts'] = false;
      }
    } else {
 
      // We know this is an additional post in that month
      $num_posts_month++;
    }
 
    // If we should continue looping at this point
    if($continue_looping) {
 
      // If we need more posts for this month
      if($num_posts_month <= $num_posts) {
 
        // Get the permalink for this post
        $permalink = get_permalink($post['ID']);
 
        // Save this post in our array
        $blog_posts[$post['month_year_slug']]['posts'][$index]['post_title'] =
        '<a href="' . $permalink . '">' . $post['post_title'] . '</a>';
      } else {
 
        // Record that there are actually more posts
        // in the database then we can display
        $blog_posts[$post['month_year_slug']]['more_posts'] = true;
      }
    }
 
    // Increment post index
    $index++;
  }
}
 
return $blog_posts;
// If there are blog posts to loop through
if(!empty($posts)) {

	// Start our counter at zero
	$index = 0;

	// Create a variable to keep track of whether we need to continue looping
	$continue_looping = true;

	// If we need to continue looping and the current post exists
	while($continue_looping && isset($posts[$index])) {

		// Store the current post
		$post = $posts[$index];

		// Assume there are no more posts in this month
		$more_posts = false;

		// If this particular month/year doesn't exist
		if(!isset($blog_posts[$post['month_year_slug']])) {

			// We know this is the first post in the month
			$num_posts_month = 1;

			// If we have posts for all $num_months, we know we're done
			if(count($blog_posts) >= $num_months) {

				// Remember that we're done looping now
				$continue_looping = false;
			} else {

				// Create a new month in our array and save the month/year
				$blog_posts[$post['month_year_slug']]['month_year'] = 
				$post['month_year'];

				// Assume and save that there are no additional posts
				// in the database for that month
				$blog_posts[$post['month_year_slug']]['more_posts'] = false;
			}
		} else {

			// We know this is an additional post in that month
			$num_posts_month++;
		}

		// If we should continue looping at this point
		if($continue_looping) {

			// If we need more posts for this month
			if($num_posts_month <= $num_posts) {

				// Get the permalink for this post
				$permalink = get_permalink($post['ID']);

				// Save this post in our array
				$blog_posts[$post['month_year_slug']]['posts'][$index]['post_title'] =
				'<a href="' . $permalink . '">' . $post['post_title'] . '</a>';
			} else {

				// Record that there are actually more posts
				// in the database then we can display
				$blog_posts[$post['month_year_slug']]['more_posts'] = true;
			}
		}

		// Increment post index
		$index++;
	}
}

return $blog_posts;

If you have any questions or suggestions, I’d love to hear them in the comments below!

-RS