AD7six.com

Keyboard, coffee and code.

CakePHP 3.x - Entity Routing

One of the most satisfying bits of code to write, are those simple solutions to simple (or overlooked) problems. The problem I’m going to write about today is Routing.

What’s wrong with routing?

Nothing.

However, that’s not to say it can’t be improved. To set the scene take a look at the 2.x docs for routing and you’ll find the following example:

Example standard route
1
2
3
4
5
Router::connect(
    '/:controller/:id',
    ['action' => 'view'],
    ['id' => '[0-9]+']
);

No surprises there. So the above route would be used like so:

Example standard link
1
2
3
4
5
6
echo $this->Html->link(
  $post['title'],
  ['controller' => 'posts', 'action' => 'view', 'id' => $post['id']]
);

// outputting a link to /posts/view/123

Unless your application is trivial, it’s quite likely to have your view files full of code like this, and there’s nothing wrong with that, as it’s the standard way to write a url that uses reverse routing (taking an array and returning a string).

However, it’s normally when you already have a chunk of code written that you get a curveball come your way such as:

Whoops.

We forgot about seo - we need to put some of that magic sauce in the url. All urls.

So now, your route (and relevant controller code if necessary) gets updated to e.g.:

Example slug route
1
2
3
4
5
Router::connect(
    '/:controller/:slug',
    ['action' => 'view'],
    ['slug' => '[0-9a-z-]+']
);

And view code (and controller redirects) needs to change to match like so:

Example slug link
1
2
3
4
echo $this->Html->link(
  $post['title'],
  ['controller' => 'posts', 'action' => 'view', 'slug' => $post['slug']]
);

Rinse and repeat a few times and this gets pretty tedious. What if there was a better way?

A better way

Let’s jump straight into designing a solution and then make it work.

I’m going to make use of named routes as this both reduces verbosity and is a minor speed up - since it means telling the router explicitly which route to use, instead of asking it to itterate over all route definitions looking for a match.

This is the end result we’re going to achieve:

entity route
1
2
3
4
5
6
7
8
9
echo $this->Html->link(
  $post['title'],
  ['_name' => 'postsView', '_entity' => $post]
);

// outputting a link to /posts/view/123
//                   or /posts/123
//                   or /posts/cakephp-3-0-entity-routing
//                   or /whatever/you/want

Note: Entity Routing IS NOT a CakePHP 3.x core feature - the above will not “just work”

Now let’s think about that for a second:

  • The call contains the entity, it doesn’t explicitly say which bits of the entity are in the url
  • Changing only the route definition will change the result
  • It is unlikely to ever need to change

Can you feel the awesome?

Making it work

We only need 2 things for the above to work

1. An appropriate route definition

We’ll need a named route definition, and to specify to use a different route class:

entity route definition
1
2
3
4
5
$routes->connect(
  '/posts/:slug',
  ['controller' => 'Posts', 'action' => 'view'],
  ['_name' => 'postsView', 'routeClass' => 'EntityRoute']
);

2. The entity-route class

And, to create the route class:

App\Routing\Route\EntityRoute.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
namespace App\Routing\Route;

use Cake\Routing\Route\Route;

class EntityRoute extends Route {

    public function match(array $url, array $context = []) {

        if (isset($url['_entity'])) {

            $entity = $url['_entity'];
            preg_match_all('@:(\w+)@', $this->template, $matches);

            foreach($matches[1] as $field) {
                $url[$field] = $entity[$field];
            }

        }

        return parent::match($url, $context);
    }

}

All the above route does is use the route template (/posts/:slug) to identify which properties to read from the passed entity if it is present (slug), otherwise it will act exactly the same as a standard route.

With the above route definition and route class all of these would return the same string:

entity route
1
2
3
echo Router::url(['controller' => 'posts', 'action' => 'view', 'slug' => $post['slug']]);
echo Router::url(['_name' => 'postsView', 'slug' => $post['slug']]);
echo Router::url(['_name' => 'postsView', '_entity' => $post]);

But only one of the above calls is imune to “significant” route changes.

Wrapping up

Entity routing is a rather simple change.

One thing that it has encouraged me to do is to consider creating bespoke route classes more often, for example, have some sort of “make sure this parameter is always set” logic in your app controller before filter? Why not put that logic in the route definition and forget about it?

That’s all for now, let me know what you think =).

Comments