2

I have a website that has users and entries, both of which are stored in a database. Every entry has its own page, using its slug, and every user has a public profile, using its username. Their respective URLs might look something like this:

  • Entry: https://example.com/hello-world
  • User: https://example.com/test-user

Refactoring said website with CakePHP 3.4 (the original being built with “vanilla” PHP), I implemented the following routes:

$routes->connect('/:slug',
    ['controller' => 'Entries', 'action' => 'view'],
    ['pass' => ['slug']]
);

$routes->connect('/:username',
    ['controller' => 'Users', 'action' => 'view'],
    ['pass' => ['username']]
);

The entry pages work like a charm — no problem there — but when I try to access a user profile, Cake throws a RecordNotFoundException. This makes sense, since it’s looking for an entry that does no exist.

I was hoping switching from firstOrFail to find in the EntriesController would allow the application to continue with the next route in line (because no exception would be thrown), but the result is actually worse: It tries to render the entry view without an object, causing PHP notices on an otherwise empty layout.

I have read the CakePHP documentation (“Book”), but could not find a solution to this (I would assume rather generic) problem. I have also tried many other (often less obvious) route setups, but no luck there either.

Now my mind keeps going to something like a EntryOrUserController, but I doubt that would be the best solution, or even a good one. Frankly, I think it’s silly. I guess I am really hoping for some controller or middleware function that does exactly what I want out of the box, but any elegant solution would do.

P.S. I do realize that the default CakePHP/MVC way of going about this would be to have URLs a bit more like this:

  • Entry: https://example.com/entries/hello-world
  • User: https://example.com/users/test-user

…but that is not an option in this case, so thanks but no. ☺

ACJ
  • 2,499
  • 3
  • 24
  • 27
  • 2
    One option would be a custom route class that tests for a records existence: **https://stackoverflow.com/questions/34360539/mapping-slugs-from-database-in-routing** – ndm Aug 04 '17 at 16:54
  • Thanks, @ndm! I posted an answer based on my findings there. – ACJ Aug 07 '17 at 09:36

1 Answers1

1

Thanks to ndm for pointing me in the right direction. After some tinkering I have a solution that works nicely. Posting it here because it might be of use to others. There are three steps.

1. Create custom routing class

/src/Routing/Route/SlugRoute.php:

namespace App\Routing\Route;

use Cake\Routing\Route\Route;
use Cake\ORM\Locator\LocatorAwareTrait;

class SlugRoute extends Route
{
    use LocatorAwareTrait;

    public function parse($url, $method = '')
    {
        $params = parent::parse($url, $method);

        if (!$params ||
            !isset($this->options['model']) ||
            !isset($this->options['pass'][0])
        ) {
            return false;
        }

        $count = $this
            ->tableLocator()
            ->get($this->options['model'])
            ->find()
            ->where([
                $this->options['pass'][0] => $params['pass'][0]
            ])
            ->count();

        if ($count !== 1) {
            return false;
        }

        return $params;
    }
}

2. Apply new routing class to relevant routes

$routes->connect('/:slug',
    ['controller' => 'Entries', 'action' => 'view'],
    ['pass' => ['slug', 'name'], 'routeClass' => 'SlugRoute', 'model' => 'Entries']
);

$routes->connect('/:username',
    ['controller' => 'Users', 'action' => 'view'],
    ['pass' => ['username'], 'routeClass' => 'SlugRoute', 'model' => 'Users']
);

3. Consider a few things

  • I’m also telling the routes which models to use. It would probably be nice if the routing class figures this out by itself.
  • The routing class assumes we want to do a lookup on the first value of the pass array. That’s fine in this case (with only slug and username being passed), but it’s not very transparent and easily broken.
ACJ
  • 2,499
  • 3
  • 24
  • 27