Skip to content

Rewrite Rules: Add opt-in support for random content redirects. - #13022

Closed
mlaetitia wants to merge 3 commits into
WordPress:trunkfrom
mlaetitia:trac-64498-random-content-redirect
Closed

mlaetitia wants to merge 3 commits into
WordPress:trunkfrom
mlaetitia:trac-64498-random-content-redirect

Conversation

@mlaetitia

@mlaetitia mlaetitia commented Aug 12, 2026 •

Copy link
Copy Markdown

Trac ticket: https://core.trac.wordpress.org/ticket/64498

This PR adds a minimal, opt-in implementation of random content redirects

What it does

  • Adds wp_random_content_redirect() (in wp-includes/query.php, alongside its closest sibling wp_old_slug_redirect()), hooked on template_redirect.

  • When enabled, a request to example.com/?random issues a 302 redirect to a randomly selected published post. ?random&random_post_type=page restricts the pick to a post type (viewable types only); ?random&random_cat_id=<term ID> restricts it to a category. These parameter names match Matt's original Random Redirect plugin referenced in the ticket, so existing ?random links keep working.

  • Disabled by default — per the ticket's request for an opt-in implementation with no user-facing settings or defaults, nothing happens unless a theme or plugin opts in:

    add_filter( 'enable_random_content_redirect', '__return_true' );
  • A second filter, random_content_redirect_url, allows short-circuiting or rewriting the redirect target (same pattern as old_slug_redirect_url).

Implementation notes

  • The random pick uses a COUNT plus a random OFFSET (two WP_Query calls) rather than ORDER BY RAND(), which randomizes and sorts every candidate row on each request and is uncached.
  • Password-protected and non-published posts are never redirect targets. Non-viewable post types are rejected. Only GET/HEAD requests are handled.
  • The redirect is a 302 so clients do not cache the randomly picked target.
  • Open question for review: the parameters are read from $_GET on template_redirect, matching the original plugin's addressing. If pretty-URL support (e.g. a /random/ rewrite endpoint) is desirable, the parameters should instead become registered query vars — happy to rework in that direction if preferred.

Tests

tests/phpunit/tests/rewrite/randomContentRedirect.php covers: disabled-by-default behavior, no-op without the random parameter, redirect to a published post, post type and category restriction, exclusion of password-protected/draft/private posts, invalid and non-viewable post types, nonexistent categories, empty result sets, and non-GET requests.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Fable 5
Used for: Initial implementation and test suite, ported from the Jetpack Random Redirect module under my direction; reviewed, tested, and edited by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

mlaetitia and others added 2 commits August 12, 2026 17:24
Adds wp_random_content_redirect(), hooked on template_redirect, which
redirects ?random requests to a randomly selected published post.
Disabled by default; enable with the enable_random_content_redirect
filter. Picks via COUNT plus a random OFFSET instead of ORDER BY RAND().

See https://core.trac.wordpress.org/ticket/64498

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Check for the random parameter before evaluating the enable filter so
disabled sites pay no filter lookup per request. Validate post type
viewability unconditionally, prime the post cache for get_permalink(),
skip the second query when the random offset is zero, and drop test
teardown that the test framework already guarantees.

See https://core.trac.wordpress.org/ticket/64498

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props mlaetitia, ellatrix, mcsf, annezazu.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@ellatrix ellatrix left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some nits, leaving some time for others to see this and can commit if you're ready.

Comment thread src/wp-includes/query.php
}

// Temporary redirect, so clients do not cache the randomly picked target.
wp_safe_redirect( $link, 302 );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also call nocache_headers?

@mcsf mcsf Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose the intent here is to not cache the redirection (hence 302), while allowing clients to cache the resolved page (i.e. the randomly selected post). No?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it cache based on the URL? I'm not a page cache expert, hence my comment being formatted as a question 🙂

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks both for the comments. I ended up adding a nocache_headers as we actually want both as much as possible:

  • the 302 makes the redirect non-permanent
  • stop intermediary caches (CDNs, full-page caches) from pinning the redirect, which would make ?random return the same post for everyone

Thanks! Hadn't considered this properly

Comment thread src/wp-includes/query.php
if ( isset( $_GET['random_cat_id'] ) ) {
$cat_id = absint( wp_unslash( $_GET['random_cat_id'] ) );

if ( $cat_id < 1 ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

absint always returns positive? Should it be (int) instead maybe?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch! I changed this and also on the same note changed the test that checked the negative value as it was generally failing (as it should) but mostly for the wrong reasons.
changed both on the last commit

}

public function test_head_request_redirects() {
$_SERVER['REQUEST_METHOD'] = 'HEAD';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to restore this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same question about superglobals and hooks, but I then saw that everything is saved and restored:

if ( ! self::$hooks_saved ) {
$this->_backup_hooks();
}
$this->clean_up_global_scope();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait, no, $_SERVER isn't included here, but my brain really wanted it to be:

public function clean_up_global_scope() {
$_GET = array();
$_POST = array();
$_REQUEST = array();
self::flush_cache();
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the second check @mcsf - if I am not mistaken $_SERVER variables are cleaned later on in (assuming WP_RUN_CORE_TESTS is set):

function tests_reset__SERVER() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid
$_SERVER['HTTP_HOST'] = WP_TESTS_DOMAIN;
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '';
$_SERVER['SERVER_NAME'] = WP_TESTS_DOMAIN;
$_SERVER['SERVER_PORT'] = '80';
$_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1';
unset( $_SERVER['HTTP_REFERER'] );
unset( $_SERVER['HTTPS'] );
}

Would you prefer if I added a tear_down anyway?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @mlaetitia! OK, that looks good then, might not need explicit tearing down.

(I suggest verifying locally that the global is correctly reset, for good measure, but analytically it looks good.)

Comment thread src/wp-includes/query.php Outdated
return;
}

if ( isset( $_SERVER['REQUEST_METHOD'] ) && ! in_array( strtoupper( $_SERVER['REQUEST_METHOD'] ), array( 'GET', 'HEAD' ), true ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if by design, but if $_SERVER['REQUEST_METHOD'] isn't set, it will not return early.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would it not be set?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally it wouldn't (maybe some CLI scenarios?) , but I rewrote this to explicitly accept only GET or HEAD.
Note: I didn't include the new QUERY method since Core doesn't yet support it (so can't imagine a scenario where it would be sent)

}

public function test_head_request_redirects() {
$_SERVER['REQUEST_METHOD'] = 'HEAD';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same question about superglobals and hooks, but I then saw that everything is saved and restored:

if ( ! self::$hooks_saved ) {
$this->_backup_hooks();
}
$this->clean_up_global_scope();

@mlaetitia
mlaetitia requested a review from mcsf August 13, 2026 18:06

@mcsf mcsf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me, but I'd feel extra confident if maybe @desrosj had a look too — if you can spare a moment. :)

}

public function test_head_request_redirects() {
$_SERVER['REQUEST_METHOD'] = 'HEAD';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait, no, $_SERVER isn't included here, but my brain really wanted it to be:

public function clean_up_global_scope() {
$_GET = array();
$_POST = array();
$_REQUEST = array();
self::flush_cache();
}

@annezazu

Copy link
Copy Markdown

@desrosj nudging here for when you have a chance to review.

wporg-sync pushed a commit that referenced this pull request Sep 21, 2026
Add opt-in support for random content redirects, so that a page request with the `?random` query argument set will return a 302 redirect to a randomly selected published post.

The pool of eligible posts may be filtered with two additional query arguments:
- `random_post_type`
- `random_cat_id`

The feature may be opted into using:

{{{
add_filter( 'enable_random_content_redirect', '__return_true' );
}}}

Developed in: #13022

Props mlaetitia.
Fixes #64498.



git-svn-id: https://develop.svn.wordpress.org/trunk@63767 602fd350-edb4-49c9-b593-d223f7449a82
@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 63767
GitHub commit: 86b922f

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions Bot closed this Sep 21, 2026
wporg-sync pushed a commit to WordPress/WordPress that referenced this pull request Sep 21, 2026
Add opt-in support for random content redirects, so that a page request with the `?random` query argument set will return a 302 redirect to a randomly selected published post.

The pool of eligible posts may be filtered with two additional query arguments:
- `random_post_type`
- `random_cat_id`

The feature may be opted into using:

{{{
add_filter( 'enable_random_content_redirect', '__return_true' );
}}}

Developed in: WordPress/wordpress-develop#13022

Props mlaetitia.
Fixes #64498.


Built from https://develop.svn.wordpress.org/trunk@63767


git-svn-id: http://core.svn.wordpress.org/trunk@62939 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Comment thread src/wp-includes/query.php
Comment on lines +1350 to +1351
wp_safe_redirect( $link, 302 );
exit;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To facilitate unit test coverage of this line, this could instead be:

Suggested change
wp_safe_redirect( $link, 302 );
exit;
if ( wp_safe_redirect( $link, 302 ) ) {
exit;
}

Comment thread src/wp-includes/query.php
*/
function wp_random_content_redirect() {
// The `random` parameter is a flag, present with or without a value.
if ( ! isset( $_GET['random'] ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of reading from $_GET, shouldn't random be added to \WP::$public_query_vars and then here it can do:

Suggested change
if ( ! isset( $_GET['random'] ) ) {
if ( ! get_query_var( 'random' ) ) {

Comment thread src/wp-includes/query.php
return;
}

$link = get_permalink( $query->posts[0] );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since plugins might filter the the_posts filter to remove items from the array, it may no longer be a list and $query->posts[0] could be undefined. It would be better to use array_first():

Suggested change
$link = get_permalink( $query->posts[0] );
$link = get_permalink( array_first( $query->posts ) );

Comment thread src/wp-includes/query.php

$post_type = 'post';

if ( isset( $_GET['random_post_type'] ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto above.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this should ideally check if $_GET['random_post_type'] is a string. Someone could make a request to a page like ?random=1&random_post_type[]=error and then $_GET['random_post_type'] here would be array( 'error' ). This array would get passed into sanitize_key() as an array, which would then return an empty string so that avoids a fatal error. However, the sanitize_key filter includes a second param with the original key passed, so a callback could fatal if it is expecting a string but an array is passed.

Related: Core-60745.

Suggested change
if ( isset( $_GET['random_post_type'] ) ) {
if ( isset( $_GET['random_post_type'] ) && is_string( $_GET['random_post_type'] ) ) {

Comment thread src/wp-includes/query.php
'update_post_meta_cache' => false,
);

if ( isset( $_GET['random_cat_id'] ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above about using get_query_var() but also for type safety, which is less important here because of the (int) cast:

Suggested change
if ( isset( $_GET['random_cat_id'] ) ) {
if ( isset( $_GET['random_cat_id'] ) && is_string( $_GET['random_cat_id'] ) ) {

Comment thread src/wp-includes/query.php
Comment on lines +1333 to +1346
/**
* Filters the random content redirect URL.
*
* Returning a falsey value cancels the redirect.
*
* @since 7.2.0
*
* @param string $link The redirect URL.
*/
$link = apply_filters( 'random_content_redirect_url', $link );

if ( ! $link ) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we need this? There is already a filter applying in wp_safe_redirect() including wp_redirect. The use of this filter for unit testing can be replaced with using wp_redirect.

Comment thread src/wp-includes/query.php
'order' => 'ASC',
'ignore_sticky_posts' => true,
'update_post_term_cache' => false,
'update_post_meta_cache' => false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More data is being queried than is needed:

Suggested change
'update_post_meta_cache' => false,
'update_post_meta_cache' => false,
'fields' => 'ids',

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants