0

I searched for many solutions before opening this post, but none worked correctly. This is a complicated matter because it can significantly impact the site if not done correctly.

I need to have a specific link structure for a particular category (news), for example:

Currently, my URL structure is like this:

https://example.com/post-title/

I need it to be like this:

https://example.com/category/year/month/day/post-title/

I can't change the WordPress link structure because this URL pattern would be specific to the news category. I tried something like this:

add_filter('post_link', 'custom_permalink', 10, 3);
function custom_permalink($permalink, $post, $leavename) {
    $categories = get_the_category($post->ID);        
    $news_category = array_filter($categories, function ($category) {
        return $category->slug === 'news';
    });

    if (!empty($news_category)) {
        $post_date = get_the_date('Y/m/d', $post->ID);
        $permalink = home_url("/news/{$post_date}/{$post->post_name}/");
    }

    return $permalink;
}

add_action('generate_rewrite_rules', 'custom_rewrite_rules');
function custom_rewrite_rules($wp_rewrite) {
    $new_rules['news/([0-9]{4})/([0-9]{2})/([0-9]{2})/([^/]*)/?$'] = 'index.php?category_name=news&name=$matches[4]';
    $wp_rewrite->rules = $new_rules + $wp_rewrite->rules;
    return $wp_rewrite;
}
add_action('template_redirect', 'custom_redirects');
function custom_redirects() {
    if (is_single() && in_category('news')) {
        global $post;
        $new_url = home_url("/news/" . get_the_date('Y/m/d', $post->ID) . "/{$post->post_name}/");
        if (get_permalink() !== $new_url) {
            wp_redirect($new_url, 301);
            exit;
        }
    }
}

But when I click, it gives a 404 error. I can't see where I'm going wrong...

Thanks,

3 Answers 3

0

A user posted several correction ideas; unfortunately, they were deleted, but I managed to remember some things and apply them to the code. The 404 error no longer appears; now it generates the following:

Try deleting cookies. ERR_TOO_MANY_REDIRECTS

add_filter('post_link', 'custom_permalink', 10, 3);
function custom_permalink($permalink, $post, $leavename) {
    $categories = get_the_category($post->ID);        
    $news_category = array_filter($categories, function ($category) {
        return $category->slug === 'news';
    });

    if (!empty($news_category)) {
        $post_date = get_the_date('Y/m/d', $post->ID);
        $permalink = home_url("/news/{$post_date}/{$post->post_name}/");
    }

    return $permalink;
}

add_action('init', 'custom_rewrite_rules');
function custom_rewrite_rules() {
    $rules = get_option('rewrite_rules', array());
    $regex = 'news/([0-9]{4})/([0-9]{2})/([0-9]{2})/([^/]*)/?$';
    add_rewrite_rule($regex, 'index.php?name=$matches[4]', 'top');

    if (!isset($rules[$regex])) {
        flush_rewrite_rules();
    }
}
add_action('template_redirect', 'custom_path_validation');

function custom_path_validation() {
    if (get_query_var('custom_path_redirected')) {
        return;
    }
    $request_path = trim(parse_url(add_query_arg(array()), PHP_URL_PATH), '/');
    $news_regex = '^news/(\d{4})/(\d{2})/(\d{2})/([^/]+)/?$';

    if (preg_match("~$news_regex~", $request_path, $matches)) {
        $post_name = sanitize_title($matches[4]);
        $new_url = home_url("/news/{$matches[1]}/{$matches[2]}/{$matches[3]}/$post_name/");

        if ($request_path !== ltrim(parse_url($new_url, PHP_URL_PATH), '/')) {
            set_query_var('custom_path_redirected', true);
            wp_redirect($new_url, 301);
            exit;
        }
    }
}
add_action('template_redirect', 'custom_path_validation');

I'm trying to reach a solution, as soon as I achieve it, I'll post it here. I believe it will help many users, as the solutions found so far are incomplete due to being outdated.

3
  • My original answer suggested to remove the template_redirect hook. template_redirect is likely the cause of the error: ERR_TOO_MANY_REDIRECTS.
    – kofeigen
    Dec 4, 2023 at 19:29
  • Thank you for your help
    – Jader
    Dec 4, 2023 at 19:52
  • Hi. I just updated my answer (UPDATE: 2023-12-06) to help with the problem of false positives.
    – kofeigen
    Dec 6, 2023 at 23:10
0

Kofeigen, thank you very much for your help. You were essential for the code corrections. Here is the final code, implementing specific redirection to the 'news' category. When the URL is hacked, it redirects to the homepage. I plan to make further implementations in the future. Any contributions will be greatly appreciated and added to the code.

add_filter('post_link', 'custom_permalink', 10, 3);
function custom_permalink($permalink, $post, $leavename) {
    $categories = get_the_category($post->ID);        
    $news_category = array_filter($categories, function ($category) {
        return $category->slug === 'news';
    });

    if (!empty($news_category)) {
        $post_date = get_the_date('Y/m/d', $post->ID);
        $permalink = home_url("/news/{$post_date}/{$post->post_name}/");
    }

    return $permalink;
}

add_action('init', 'custom_rewrite_rules');
function custom_rewrite_rules() {
    $rules = get_option('rewrite_rules', array());
    $regex = 'news/([0-9]{4})/([0-9]{2})/([0-9]{2})/([^/]*)/?$';
    add_rewrite_rule($regex, 'index.php?name=$matches[4]', 'top');
    if (!isset($rules[$regex])) {
        flush_rewrite_rules();
    }
}

add_action('parse_request', 'custom_path_validation');
function custom_path_validation($wp) {
    if (get_query_var('custom_path_redirected')) {
        return;
    }

    $request_path = trim(parse_url(add_query_arg(array()), PHP_URL_PATH), '/');
    $news_regex = '^news/(\d{4})/(\d{2})/(\d{2})/([^/]+)/?$';

    if (preg_match("~$news_regex~", $request_path, $matches)) {
        $post_name = sanitize_title($matches[4]);
        $new_url = home_url("/news/{$matches[1]}/{$matches[2]}/{$matches[3]}/$post_name/");

        if ($request_path !== ltrim(parse_url($new_url, PHP_URL_PATH), '/')) {
            set_query_var('custom_path_redirected', true);

            $wp->query_vars = array('name' => $matches[4]);
        }
    }
}
add_action('template_redirect', 'redirect_old_urls');

function redirect_old_urls() {
    if (is_single() && get_query_var('name')) {
        $post_id = url_to_postid(get_permalink());
        $categories = get_the_category($post_id);

        if (!empty($categories) && in_array('news', wp_list_pluck($categories, 'slug'))) {
            $post_date = get_the_date('Y/m/d', $post_id);
            $new_url = home_url("/news/{$post_date}/" . get_query_var('name') . '/');

            if (home_url(add_query_arg(array())) !== $new_url) {
                wp_redirect($new_url, 301);
                exit;
            }
        }
    }
}


add_action('init', 'custom_redirect_rules');
function custom_redirect_rules() {
    add_rewrite_rule('news/(\d{4})/(\d{2})/(\d{2})/?$', 'index.php', 'top');
}

add_action('parse_request', 'custom_redirect_check');
function custom_redirect_check($wp) {
    $request_path = trim(parse_url(add_query_arg(array()), PHP_URL_PATH), '/');
    $news_regex = '^news/(\d{4})/(\d{2})/(\d{2})/?$';

    if (preg_match("~$news_regex~", $request_path)) {
        wp_redirect(home_url(), 301);
        exit;
    }
}
0

UPDATE

The answer below was temporarily deleted because I noticed that while it works to display the desired post, it also displayed "false positives". If the name query variable is used (the post_name), it uniquely identifies the post. Therefore, the post is displayed even if the category or date is wrong.

For example, https://example.com/news/2023/12/31/post-name would still work to find the post even when the post no longer has the category news and the published date doesn't match 2023-12-31.

Testing is needed to figure out how to avoid the false positives.


Original Answer

Try the following:

Remove template_redirect

Remove the call to add the custom_redirects callback to the template_redirect action hook. It is unnecessary since the rewrite rule targets the desired post directly with a unique post_name.

Replace custom_rewrite_rules with the code below

Remove the call to add the custom_rewrite_rules callback to the generate_rewrite_rules action hook.

The WordPress documentation for WP_Rewrite says:

Try not to access or set the properties directly, instead use the methods

Your generate_rewrite_rules action hook callback updates the rules property directly. Use the add_rule() method instead. However, generate_rewrite_rules probably isn't the best choice for this task anyway since, according to the WordPress documentation, it Fires after the rewrite rules are generated. So, after triggering regeneration of the rules to add the new rewrite rule, you would have to regenerate the rules a second time to save the new rule to the database.

This is why I suggest to remove the call to add the generate_rewrite_rules action hook, and replace it with the following (adapted from this code snippet from a comment in the WordPress documentation for add_rewrite_rule):

Updated custom_rewrite_rules

\add_action('init', 'custom_rewrite_rules');
function custom_rewrite_rules() {
    $rules = \get_option( 'rewrite_rules', array() );

    // set the regex.
    $regex = 'news/([0-9]{4})/([0-9]{2})/([0-9]{2})/([^/]*)/?$';

    // add the rewrite rule.
    \add_rewrite_rule( $regex, 'index.php?name=$matches[4]', 'top' );

    // flush rewrite rules if it was not previously in the option.
    if ( ! isset( $rules[ $regex ] ) ) {
       \flush_rewrite_rules();
    }
}

You probably already have the $regex string stored in the database for a rewrite rule. To remove the rule, remove your original code for custom_rewrite_rules (add_action and custom_rewrite_rules), make a new request to any WordPress page, then add the replacement code above.

custom_permalink callback

The call to add the custom_permalink callback for the post_link filter hook works fine.

URL hacking

Sometimes users remove (hack) trailing URL path segments from URLs as a way to navigate up a hierarchy of content. For example, if the URL were: https://example.com/news/2023/12/04/breaking-news/ a hacked URL such as: https://example.com/news/2023/12/04/ would be expected to show the list of all news stories published on 2023-12-04.

Currently, hacked URLs return a 404 page.

WP_Rewrite could create all the rewrite rules necessary to make hacked URLs valid. But, this requires that a permalink structure be used as the basis for creating rewrite rules. An example of something to try to make hacked URLs work is below:

To test this code, add it to your theme/plugin, then flush the rewrite rules by clicking the Save Changes button on the Admin > Settings > Permalinks page.

\add_action( 'init',  function() {
    \add_permastruct(
        'news',
        '/%category%/%year%/%monthnum%/%day%/',
        array(
            'with_front' => false,
            'ep_mask' => \EP_CATEGORIES,
            'walk_dirs' => true,
            'endpoints' => true
        )
    );
} );

For the "news" category, you could create a custom template (category-news.html) that uses a special format for lists of news stories. Another custom template (category.html) could handle all the non-news listings.


UPDATE: 2023-12-06

False positives

False positives occur with the code above (under "Updated custom_rewrite_rules") for these reasons (and maybe others).

  1. Query variables (i.e. category_name, year, month, day) that could exclude false post matches are not passed into the query string.
  2. Even if category_name were passed into the query string, WordPress would ignore it (likely because name [the post slug] uniquely identifies a post).
  3. When some rewrite rule matches fail, WordPress tries to match the URL with another rewrite rule using redirects.

The sample code below can be used to prevent an invalid URL like this:

https://example.com/news/2023/12/99/post-name

...from succeeding to match a post available at a valid URL like this:

https://example.com/news/2023/12/06/post-name

category_name variable

WordPress ignores the query variable category_name and omits it from the SQL query when a query variable is used that uniquely identifies a post (e.g. name). Below, the function exclude_false_positives uses the posts_clauses filter hook to update the SQL query. It adds a category_name condition to the WHERE clause.

This new condition can cause the SQL query to produce an empty result set. When this happens WordPress tries another rewrite rule in the sequence of rules by sometimes using a redirect (301 status). The function stop_redirecting_false_positives blocks the redirect. A blocked redirect produces a 404 status. So, a request like this, https://example.com/news/2023/12/06/valid-post-name-but-post-does-not-have-the-news-category, would trigger a 404 status.

Date-related variables

Passing the query variables year, monthnum, and day into the query string takes advantage of WP_Date_Query for validation, and to check the post's date against values submitted in the URL. These variables are added to the SQL Query. They block URLs such as https://example.com/news/2023/12/99/post-name from finding posts. add_rewrite_rule regular expressions for day had to be updated (compared to code above under "Updated custom_rewrite_rules") to avoid WordPress error messages for invalid values.

URL hacking

add_permastruct can automatically add a lot of rewrite regexes for you that cover many cases (e.g. RSS feeds, attachments, comments, trackbacks, etc.) for each hacked URL path segment sequence (/news; /news/2023; news/2023/12; news/2023/12/06). You may wish to consider using it to avoid writing home page redirect rules for every possibility. Accommodating hacked URLs might also be better for usability since the user can remain within the news section (depending on how you design your templates) rather than being sent to the home page to have to re-navigate back to news.

Having said this, add_permastruct can be tricky to use. The sequence of each call (where multiple permastructs are added) is very important. There are no options I'm aware of that let you control the exact sequence of generated rewrite rules. It is easy to mess things up and break previously working, normal URLs like https://example.com/post-name. Some have run into this problem when using custom post types.

Below, the function custom_rewrite_rules provides for URL hacking by using multiple calls to add_rewrite_rule. This does not substitute for what add_permastruct can do. But, I find add_rewrite_rule to be reliable.

As an aside: You can still benefit from most of what add_permastruct does by using the function WP_Rewrite::generate_rewrite_rules instead. This function generates an array of rewrite rules based on a permalink structure. By iterating over the array, each rewrite rule can be added with add_rewrite_rule, using the $after parameter to control sequence. (Note that the sequence of rewrite rules added by add_rewrite_rule is the order in which they appear in the code. See this WordPress Stack Exchange question: Order of rewrite rules when calling add_rewrite_rule).

Sample code

\add_action('init', 'custom_rewrite_rules', 10, 1 );

function custom_rewrite_rules() {
    $rules = \get_option( 'rewrite_rules', array() );

    // The main rule to target the post.
    // Invalid day-of-the-month numbers trigger a WordPress error message.
    // Regex was updated to validate 'day'.
    $regex = 'news/([0-9]{4})/([0-9]{2})/((?(?=3)3[0-1]|(?(?=0)0[1-9]|[1-2][0-9])))/([^/]*)/?$';
    \add_rewrite_rule( $regex, 'index.php?year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&name=$matches[4]&category_name=news', 'top' );

    // Use these four optional rules to allow URL hacking for all URL path segments.
    // Archive pages (e.g. category.html) handle an empty result set and matched results.
    $regex = 'news/([0-9]{4})/([0-9]{2})/((?(?=3)3[0-1]|(?(?=0)0[1-9]|[1-2][0-9])))/?$';
    \add_rewrite_rule( $regex, 'index.php?year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&category_name=news', 'top' );

    $regex = 'news/([0-9]{4})/([0-9]{2})/?$';
    \add_rewrite_rule( $regex, 'index.php?year=$matches[1]&monthnum=$matches[2]&category_name=news', 'top' );

    $regex = 'news/([0-9]{4})/?$';
    \add_rewrite_rule( $regex, 'index.php?year=$matches[1]&category_name=news', 'top' );

    $regex = 'news/?$';
    \add_rewrite_rule( $regex, 'index.php?category_name=news', 'top' );

    // flush rewrite rules if it was not previously in the option.
    if ( ! isset( $rules[ $regex ] ) ) {
       \flush_rewrite_rules();
    }
}
\add_filter( 'posts_clauses', 'exclude_false_positives', 10, 2 );

/**
 * Prevent valid post_names from displaying the post with invalid category
 * or date. Add missing 'category_name' conditional in the WHERE clause.
 *
 * @param array  $clauses SQL query clauses like where, join, orderby, etc.
 * @param object $query   The current (not necessarily the main) WP_Query object.
 *
 * @return array
 */
function exclude_false_positives( $clauses, $query ) {
    global $wpdb;

    if (
        $query->is_singular()
        && preg_match( '!' . "post_type = 'post'" . '!', $clauses['where'] )
        && 'news' == $query->get( 'category_name' )
    ) {
        $clauses['join'] .= <<<SQL
 INNER JOIN {$wpdb->prefix}term_relationships ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}term_relationships.object_id
 INNER JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_taxonomy.term_taxonomy_id = {$wpdb->prefix}term_relationships.term_taxonomy_id
 INNER JOIN {$wpdb->prefix}terms ON {$wpdb->prefix}terms.term_id = {$wpdb->prefix}term_taxonomy.term_id
SQL;

        $clauses['where'] .= " AND {$wpdb->prefix}terms.slug = 'news'";
    }

    return $clauses;
}
\add_filter('redirect_canonical', 'stop_redirecting_false_positives', 10, 2 );

/**
 * Prevent valid post_names from displaying the post with invalid category
 * or date. Stop redirection that tests additional rewrite rules.
 *
 * @param string $redirect_url  The redirection URL.
 * @param string $requested_url The originally requested URL.
 *
 * @return string|bool
 */
function stop_redirecting_false_positives( $redirect_url, $requested_url ) {
    $regex = 'news/([0-9]{4})/([0-9]{2})/([0-9]{2})/([^/]*)/?$';

    if ( preg_match( '!' . $regex . '!', $requested_url ) ) {
        $redirect = false;
    } else {
        $redirect = $redirect_url;
    }

    return $redirect;
}

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.