Skip to content
PN Scripts

Development

Moving 158 game pages to short addresses with one-hop 301 redirects

  • PN Scripts
  • 9 min read

Until this week every game on our games site lived at an address like games.pnscripts.com/title/wheatwhirl. The word title told visitors and search engines nothing, made every link longer and was the only part of the site built that way. On 9 November we moved all 158 public game pages to the root of the host, so the same page is now games.pnscripts.com/wheatwhirl, and its Bulgarian version /bg/wheatwhirl. This article shows how we did it in Laravel without losing a single old link, why every old address answers with exactly one redirect, and what we measured on the live site afterwards.

#Why a short address is worth a move

Google's own guidance is modest about words in addresses: they help people more than rankings. The gain is elsewhere. A short, readable address is easier to share, read aloud and trust in a chat message or a search result, and one consistent form for every page means every link, canonical tag, hreflang entry and sitemap line points at the same thing. The risk of a move is also well known: every old link, bookmark and indexed page must keep working, or the signals they carry are lost.

Google recommends a permanent server-side redirect (301 or 308) from every old address to its new one, keeping those redirects for at least a year, and updating internal links, canonical tags and sitemaps so that crawlers do not have to go through the redirect at all. It also asks site owners to avoid chains: each extra hop costs time for the visitor and crawl effort for the search engine. That is why our rule was one hop, never two.

#The new route and the old one

The games host now has two routes for game pages. The old form gets its own small route that only redirects. The new form is registered last in the group, so every real page of the host wins first:

// Old game page addresses: one permanent 301 to the page at the host root.
Route::get('/title/{slug}', [GamesController::class, 'legacyTitle'])->name('title.legacy'.$suffix);

// Game pages at the host root: /{slug} and /bg/{slug} (owner 09.11.2026, no /title/).
// Registered last in the group and never matching a reserved word or a
// language prefix (GameUrlSlugs); a games-site CMS page with this slug
// still opens through GamesController::show.
Route::get('/{slug}', [GamesController::class, 'show'])
    ->where('slug', GameUrlSlugs::routePattern())
    ->name('title.show'.$suffix);

The redirect itself is a few lines. It asks one helper for the final page in the visitor's language and keeps any query string, so campaign tags on links in old posts still arrive:

public function legacyTitle(Request $request, string $slug): RedirectResponse
{
    $target = GamePageTarget::forSlug($slug);
    abort_if($target === null, 404);

    $query = $request->getQueryString();

    return redirect()->to($query !== null && $query !== '' ? $target.'?'.$query : $target, 301);
}

#One hop, even for games that moved twice

The interesting case is not a live game, it is a game that no longer has its own page. Some of our older titles were folded into a newer one, and some were taken off the site. A naive redirect would send /title/tunefurrow to /tunefurrow, which would then redirect again to the game it was folded into. That is a chain. Instead, one helper decides the final page for any game address, and both the old route and the new one use it:

public static function forSlug(string $slug, ?string $locale = null): ?string
{
    return self::inLocale($locale, static function () use ($slug): ?string {
        $game = Game::query()->published()->where('slug', $slug)->first();
        if ($game instanceof Game) {
            return LobbyTitle::href($game);
        }

        return GameSlugRedirect::targetUrl($slug) ?? self::platformHubFor($slug);
    });
}

A live game goes to its own page. A folded game goes straight to the game that replaced it. A game taken off the site goes to the page of its platform (browser, desktop or mobile), the closest page that still helps the visitor. A title that is announced but not out yet keeps its 404, because it never had a page. The middleware that cleans up upper-case letters, a www. in front of the host and /index.php uses the same helper, so www.games.pnscripts.com/title/tunefurrow and /Title/Tunefurrow each land on the final page in one step.

#A root slug must never shadow a real page

Moving pages to the root has a hidden cost: a game slug now shares the first path segment with every other page of the host. A game called legal or play would take over the legal pages or the folder that serves the games. We keep a sorted list of 61 reserved first segments (routes, public folders, system files) and build the route pattern from it, together with the language prefixes:

public static function routePattern(): string
{
    $words = array_merge(self::RESERVED, Localization::prefixSegmentLocales());
    $alternatives = implode('|', array_map(static fn (string $word): string => preg_quote($word, '#'), $words));

    return '(?!(?:'.$alternatives.')$)'.self::SHAPE;
}

A list like this goes stale the day someone adds a route, so a test walks every registered route of the games host and every entry in the public folder and fails if one is missing from the list. The Game model and the content pages of the games site also refuse to save a slug that collides with the other, so an editor cannot break an address from the admin panel either.

A redirect protects links we do not control. Links we do control should not need it. Blog articles, product descriptions, content pages, page sections, menu items and game descriptions held old addresses, some as plain text and some inside JSON with escaped slashes. One migration rewrote them across those six tables:

return (string) preg_replace(
    '#((?:/|\\\\/)games(?:(?:/|\\\\/)bg)?|games\.pnscripts\.(?:com|local)(?:(?:/|\\\\/)bg)?)(/|\\\\/)title(?:/|\\\\/)([a-z0-9]+(?:-[a-z0-9]+)*)#',
    '$1$2$3',
    $text,
);

Its down() puts /title/ back only in front of slugs that are games, so a rollback restores exactly what it changed. A test then checks that no stored blog content still links to the old form, plain or escaped.

#Sitemaps that list only final pages

A sitemap should list the pages you want indexed, not addresses that redirect. Our sitemaps are generated from the same routes, so the new addresses appeared by themselves. We used the move to fix one more thing: before, the Bulgarian pages were listed only as alternates of the English ones. Now every language version is its own entry with the same reciprocal set of hreflang links, which is the form Google documents for multilingual sitemaps. Our guide to multilingual sites explains why that symmetry matters.

#Testing one hop

The feature tests do not stop at the status code. Each check follows the Location header once and requires a 200, so a chain fails the test:

private function assertOneHop(string $from, string $to): void
{
    $response = $this->fresh()->get($this->games($from));
    $response->assertStatus(301);
    $location = (string) $response->headers->get('Location');
    $this->assertSame($this->games($to), $location, $from);

    // The target answers 200 itself: no second hop.
    $this->fresh()->get($location)->assertOk();
}

The same helper covers the Bulgarian form, a campaign tag in the query string and a folded game, where /title/tunefurrow must go straight to /gleamtide.

#What the live site answers

Tests run against a test database. To know what visitors get, we asked the live site on 9 November, one request at a time with a pause between them and with redirects not followed, for the old address of every game we have ever published:

CheckResult
Old /title/ addresses asked252
Answered one 301250
to the game's own page158
to the game it was folded into5
to its platform page (game taken off the site)87
404 (announced titles without a page yet)2
Redirect targets that answer 200 themselves161/161
Bulgarian old addresses, random sample25/25, one hop each
Median time of a redirect answer (whole request, new connection)216 ms
/title/ links in the four sitemaps0

Before the move, our crawl of the four sitemaps had found 315 game addresses with /title/ in them, counting both languages. Now there are none, and the games sitemap lists 330 addresses, among them all 316 game pages: 158 in English and 158 in Bulgarian, each as its own entry.

#What is left after the move

Three things happen outside the code. The sitemaps are submitted again in Search Console so the new addresses are crawled sooner, and for a few weeks the old ones will show up there as pages with a redirect, which is expected rather than an error. Links in our own social posts and store descriptions are switched to the new form as they are next edited; until then the redirect carries them. The redirects themselves stay in the code for good, because old links on other sites never fully disappear.

All 158 games are free and open in the PN Scripts games catalogue. If you want the same care for your own site, our article on FAQ sections that search engines understand is a good next read.

#Frequently asked questions

Should I use 301 or 308 when I move pages?

Both are permanent redirects and Google treats them the same way. We use 301 because every client understands it and our game pages are only ever read with GET.

Why does one redirect hop matter if browsers follow chains anyway?

Every hop is another round trip for the visitor and another request for the crawler, and long chains may not be followed to the end. Pointing every old address straight at the final page removes that cost and leaves nothing to break when a page moves again.

Do I have to rewrite internal links if the redirect works?

Yes. Redirects are for links you cannot change. Internal links, canonical tags, hreflang entries and sitemaps should name the final address, so readers and crawlers never need the redirect.

How long should old redirects stay?

Google advises at least a year. We keep ours permanently, because they cost one small route and old links keep arriving from posts, forums and bookmarks for years.

#Sources

Keep reading

Keep reading

Comments

Comments

No comments yet. Ask a question or share what worked for you.

Leave a comment

shown with your comment; a nickname is fine
optional; only for reply notifications, never shown
Plain text with **bold**, *italic*, `code`, > quotes and links.

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile and API development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here