Backend Development

How to Create Multiple Wildcard Routes at the Same Root Path in Laravel.

Sometimes, you may want to make sure that multiple types of resources are available at a same root wildcard path on your applications website.

For example, you may want to access blog articles at a /blog-article-slug root URL, and you may also want to find custom pages like /privacy-policy at the root URL as well.

Laravel Route Model Binding

In Laravel, there is a nice feature called route model binding that works for most dynamic resource routing requirements. In order to utilize them, you define a route in your route config file, and implicitly or explicitly bind that route to a model that you have defined in your Models directory.

Route definition

Laravel will automatically use the variable name you use in your route definition to find the appropriate model, or you can set the type of resource you are expecting directly in your controller method parameters, which is most recommended.

For our example of /blog-slug-url we would first define the route in the routes file, defining the route with the static function get() on the Route façade.

Then, we would write a callback function or controller that the route should use to handle the request.

//In routes/web.php
<?php
use App\Http\Controllers\BlogController;

Route::get('/{blog}', [BlogController::class, 'show'])->name('pages.show');

In the above example, we are adding a wildcard route parameter for any blog in the curly places, and telling our application to handle the request in the Blog Controller using the show() method.

Model controllers

Just so you know what a blog controller may look like, I’ve provided an example here:

//in App/Http/Controllers/BlogController.php
<?php

namespace App\Http\Controllers;

use App\Models\Blog;
use Illuminate\Http\Request;

class BlogController extends Controller
{
    /**
     * Display a blog article
     *
     * @return \Illuminate\Http\Response
     */
    public function show(Blog $blog)
    {
        return view('blogs.show', compact('blog'));
    }
}

Most of the times, you will want your routes to be nested in logical resource directories, which for our needs, we would probably define our example blog route to be /blog/blog-slug-url, making it easy to define routes and use model binding for multiple kinds of resources.

/blog/blog-url //This goes to a blog page
/blog //This goes to an index view of all recent blogs.
/services/service-url //This goes to a service page
/services // This goes to a list of services

You get the idea.

For most web applications, this is exactly the type of behavior you should implement and expect.

Routes with Same Root Path

But, in some cases, you may have a need to put multiple types of resources on the same root directory. Like in the example I started with, you may want to put blogs and custom pages all at the same logical place in the app.

This is where things will get tricky.

Requests made to the path: the first route defined will be served

The issue is that our app doesn’t care at all about what you put between the curly braces. If the route matches, it will automatically serve the request to the first wildcard route in your app, no matter what comes after.

This will automatically cause an issue in our case. If we define the route for /blog-slug first, then the app will send requests made to /about to the same controller as the blogs!

This means we cannot use Laravel’s route model binding in these cases.

Use a switch controller

In order to ensure that requests are sent correctly to either the blog controller for blogs, or the page controller for custom pages, we must create an intermediary controller. This controller will act as a switch for our app to tell it which controller is most appropriate for the request it is being served.

Define the route to the controller

So, instead of defining two routes for both blogs and pages, we should define one route that both resources use to handle a matching request.

Here is what that looks like:

// In routes/web.php
<?php
use App\Http\Controllers\ResolvePathController; //You can name this controller whatever makes sense to you.

Route::get('/{blogOrPageSlug}', [ResolvePathController::class, 'show'])->name('pages.show');As you can see, the curly brace variable changed. The name of that variable really doesn’t matter. I’ve named it “blogOrPageSlug” because that is a name that makes sense to me.

You can also notice that I’m not using either the Blog controller or the Page controller in our route file, but telling the app to use a new controller that acts as the switch for our app.

Create the controller

Then, we need to create that new controller, accept the slug and request as parameters, and use the slug to see which controller to direct our requests to.

In our case, the slug either references a blog or a page stored in our database.

That should help us out a lot, so we know that we need to look for a matching slug in one of those resources.

If we can’t find the first resource, we know that the slug either references the other model, or it doesn’t exist.

//In App/Http/Controllers/ResolvePathController.php
<?php

namespace App\Http\Controllers;

use App\Models\Blog;
use App\Models\Page;
use Illuminate\Http\Request;

class ResolvePathController extends Controller
{
    public function show(Request $request, string $pageOrBlogSlug)
    {
        $page = Page::whereSlug($pageOrBlogSlug)->first();
        if ($page) {
            return PageController::show($page);
        }

        $blog = Blog::whereSlug($pageOrBlogSlug)->firstOrFail();
        return BlogController::show($request, $blog);
    }
}

Search mindfully

In our example application, first we’re searching our database for pages. I chose to search pages first, because I am guessing that eventually our app will have fewer custom pages than blog articles.

Since we have to search through one of the resources before the other, it makes sense to me to search through the smallest dataset first, as it wouldn’t be good if we had to search through every blog article slug in the database each time a user wanted to visit the about page. That’s up to you to decide though.

Don't return a failure if you can't find the first model resource

I’m also only using the first() method on the collection for the page model. I want to check if there is a page but be able to check a blog if the page doesn’t exist.

On the final search, return a 404 if the resource is not found.

On the second call to look for blogs, though, I am using firstOrFail() because we want to return a 404 page if no blog article exists with the slug that is requested. If you needed to have more than two Models you are wanting to direct users to, then you will want to make that call the first() method as well but make sure the last model you search is returning a 404 for unfound resources.

Call model controllers statically

One other thing that you may notice is that we are returning a call to the controllers methods statically. This is a very important change that you will need to make to your controller methods in order to call them properly, you must make those controller methods static methods so that they can be called this way.

In our example PageController:

<?php

namespace App\Http\Controllers;

use App\Models\Page;
use Illuminate\Http\Request;

class PageController extends Controller
{
    /**
     * Display a page
     * This has to be static, because we call this from the resolve path controller.
     *
     * @param Page $page
     * @return \Illuminate\View\View
     */
    public static function show(Page $page)
    {
        return view('pages.show', \compact('page'));
    }
}

This controller method is different than other controller methods because we are not instantiating the controller class when directing the app to use the show method. There may be a better way to do this but I felt this was a nice way.

Make sure you also you update the show method of your Blog Controller in the same way.

A Couple Things to Consider

Validate slug uniqueness

One thing to think about is validation.

What happens if a user creates a page with the same slug as a blog article? Well, no-one will ever be directed to the blog article if they use that slug, making the article removed from the site.

You will run into some major painful bugs if you don’t include validation on both models slugs to ensure slugs are never the same. If not, you may be clicking on an article link that should take you to an article page, and instead you keep getting to the same picky custom page.

Index slug columns on the database

Another thing to think about is site speed. You will definitely want to index your slug column on your database tables in order to ensure lookup time is as fast as possible. I would try to steer clear away from this type of resource structuring as much as possible as well.

Conclusion

This is something I thought about a long time, and thankfully had an incredible senior engineer Alex Topal to bounce the idea off of. Within a couple minutes he had this solution and we were able to implement this much faster.

I’m writing this down so that you hopefully don’t have to spend too much time thinking about a way to do this, and can quickly get going the next time you run into a feature requesting this.

Chris Wray website logo

Trying to do my part in making the world a better place.

This site is hosted for free, so I am thanking the services I am using by adding a link to their websites here in the footer.

© 2020 Chris Wray. All rights reserved.