PHP 8.2 and Vanilla Routing

Jeremy Moormann
4 min readOct 12, 2023

--

The release of PHP version 8.2 came with a slew of new features and syntax additions/changes that have really started to raise eyebrows in the web dev community. It has even gained some praise from the OOP crowd, specifically, with new type-related features that resembled other OOP languages, like null coalescing and annotations, and even new features exclusive to PHP, namely the new match() function.

Overall, PHP 8.x revitalized itself so much that you can technically write a whole web app using just vanilla PHP 8.2. Obviously, for larger projects, a framework is highly suggested. However, the fact that you can using just the built-in functions and features really just shows how far PHP has come.

So I did just that :)

I recently created a small Twitter/X clone for fun using nothing but PHP 8.2 for the backend and Vue.js ESM for the frontend. No build tools, no composer, just plain ole code. Aside from the frontend JS, everything else was all written in vanilla PHP 8, and in particular, the routing logic.

Making a match

When I first heard about the match()function and it’s syntax, I immediately saw the potential for application routing. With the simpler syntax and case fallback functionality, I saw that it could be used to create a very small and efficient router:

(match($_SERVER['REQUEST_URI']) {
'/' => fn() => 'Hello!',
'/about' => fn() => 'About me...',
default => fn() => 'Page not found!'
})();

The above code will output the string returned from the “route handler” function assigned to the case that matches the request URI coming in. For the most part, this satisfies a lot of features for a routing system. It has route definitions (case), router handlers (functions), and even 404 handling (default case fallback). You could even use this for a really simple brochure site with basic include()‘s for templating; really powerful stuff for such a small amount of code.

Now, what about something more realistic. Obviously, no one wants to write all that regex just for parsing out URI parameters. And what about multiple HTTP methods, dynamic routing, and middleware? Can we still use match() to do all those things, too?

Making a better match

Important thing to note about match() is that the cases are not limited to just primitive types like strings and integers. As long as the result of the key is a primitive, you can put a function in it’s place in the syntax to return, for example, a boolean for whether

Let’s start by make things a little more readable and manageable. First, we’ll pull out the case generation into its own function called isRoute() :

function isRoute(string $method, string $route, array ...$handlers): int
{
global $params;
$uri = parse_url($_SERVER['REQUEST_URI'])['path'];
$route_rgx = preg_replace('#:(\w+)#','(?<$1>(\S+))', $route);
return preg_match("#^$route_rgx$#", $uri, $params);
}

This function will take an HTTP method (GET, POST, PUT, etc.), a route URI to match on, and a route handler. It then takes the route URI and creates a regex pattern, replacing any instances of :parameter in the string with a named matching group using the name of the parameter for the group key. When run through preg_match() , the $params match results variable will automatically contain any URI parameters by its key as defined in the route URI.

For the purposes of this example, I’m using the global keyword to bubble the resulting $params variable up to the global scope, which will make it accessible from within the handler functions using the same keyword. Naturally, you’d want to avoid using global variables in programming, but this is just to show that the parameters in the URI are being parsed and are useable in the handlers.

Now, to see how this will work in the match() logic, we will define each case by calling isRoute() instead of using a primitive. Ultimately, the function will return a primitive of either 1 or 0. If 1, the condition in the argument for match() will be satisfied, and that case’s value (our route handler) will be returned as the matched value. To invoke this returned function handler, we wrap the match() function in self-invoking parentheses.

After adding a simple JSON helper function to make returning from the handler easier, we end up with something like this:

function isRoute(string $method, string $route, array ...$handlers): int
{
global $params;
$uri = parse_url($_SERVER['REQUEST_URI'])['path'];
$route_rgx = preg_replace('#:(\w+)#','(?<$1>(\S+))', $route);
return preg_match("#^$route_rgx$#", $uri, $params);
}

function json(mixed $data)
{
header('Content-Type: application/json');
echo json_encode($data);
exit;
}

(match(1) {
isRoute('GET', '/') => function () {
json(['msg' => 'Hello!']);
},
isRoute('POST', '/api/posts') => function () {
json(['msg' => 'Created post']);
},
isRoute('GET', '/api/posts/:id') => function () {
global $params;
json(['id' => $params['id']]);
},
default => fn() => json(['err' => 'Route not found!'])
})();

While this might be rough around the edges, it does showcase some pretty good mechanics of how the match() function can be used for routing. And this can very much be cleaned up and refactored to avoid using global variables.

Overall, this just goes to show how much you can do with the built-in methods in PHP 8.2, and how existing features can be reused in different ways.

--

--