A minimalist redirect solution that intercepts 404 errors

Sharing this in case it is of value to somebody else, but also because I am curious did I overlook something – almost feels a bit too simple to be true? :thinking: Pointers to possible issues and improvement suggestions are of course most welcome.

I was looking for the most simple solution to intercept 404 errors and redirect some of them to alternative URLs instead.

My website is almost 17 years old and has accrued dozens of odd forwarding rules as its blog’s URL structure has changed over time. Of course the Retour and kirby3-redirects plugins provide rich features and are configurable through the panel, yet I was looking for a minimalist hardcoded way to catch “Not found” errors and check if a rule exists where that content lives today.

Here’s the shortest I could come up with (still wondering whether the preg_match and preg_replace couldn’t be somehow merged into one):

// in /site/config/config.php
return [
  'hooks' => [
    'route:after' => function ($path, $result) {
      if($result === null) {
        $redirects = json_decode(F::read(__DIR__ . '/redirects.json') ?? []);
        foreach ($redirects as $redirect) {
          if (preg_match('#' . $redirect[0] . '#', $path, $matches)) {
            $target = preg_replace('#' . $redirect[0] . '#', $redirect[1], $path);
            die(Response::redirect($target, $redirect[2] ?? 302));
          }
        }
      }
    },
  ]
]

I decided to outsource the redirect config into a separate file, as it really is a long list by now (way too long to configure as rewrite rules in my .htaccess; also, this way it plays nicely with both Apache and Nginx with one single source of truth).

// as /site/config/redirects.json
[
  [
    "^blog/(.*?)$",
    "/journal/$1",
    301
  ],
  [
    "^bookmark/(1234|1235)$",
    "/2005/11/bookmark-$1",
    301
  ]
  // …and so on
]

The regular expressions in the JSON file follow the same scheme as the RewriteRule syntax used in .htaccess files, and the integer defines the HTTP code to be returned. If no matching rule is found, Kirby returns the regular error page.

3 Likes

i do love the regex you used.

i quiet often do not use my redirects plugin with the blueprint setup but just load a yaml or json file using the plugins bnomei.redirects.map option. if i need extra speed i include a php file instead. overall it works pretty much as yours but not as beautifully minimalistic.

:star_struck:

1 Like

With the following hook, it is possible to extend above code to track changes of published pages’ slugs and auto-create HTTP 302 redirects to their new location:

'page.changeSlug:after' => function ($newPage, $oldPage) {
  if ($oldPage->status() != 'draft' && $newPage->status() == 'draft') {
    $redirects = json_decode(F::read(__DIR__ . '/redirects.json') ?? []);
    if ($oldPage->childrenAndDrafts()->isNotEmpty()) {
      $redirects[] = [
        '^' . $oldPage->id() . '(/.*)?$',
        '/' . $newPage->id() . '$1',
        302
      ];
    } else {
      $redirects[] = [
        '^' . $oldPage->id() . '$',
        '/' . $newPage->id(),
        302
      ];
    }
    F::write(__DIR__ . '/redirects.json', json_encode($redirects,  JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
  }
}

This, however, is indeed a truly “minimalist” implementation; really more an idea than a solution (I use it for a very specific use case, and with additional if clauses to match my needs). As this discussion exemplifies, auto-tracking URL changes generally is anything but a straightforward task.

Updated to catch unlisted pages, plus include children in regex when changing slug of page with descendants.

3 Likes