My Goals
First I will explain what I wanted to achieve. In addition to "normal" routing for four types of content (Checklists, Observations, Taxa and Articles), I also wanted to include routing for the following scenario:
- Articles, Checklists and Taxa to be accessible using their ID or slug
- Articles to also be accessible by category/slug
- "Obsolete" taxa to redirect to the taxa that superseded them
- Taxa to be accessible using their links from my old system
- Checklists to be accessible using their links from my old system
The "normal" routing
Before moving onto the exceptions, I will explain how the base routing is set up.
I have a few static pages, for which routes are set up in a very standard way in route\web.php
, like this:
Route::get('/acknowledgements', function () {
return view('acknowledgements');
})->name('acknowledgements');
I have model controllers for all of models that are accessible from pages, so I use resource routing to set up all the possible routes. This varies depending on the model. I have no facilities for creating or editing observations on my site. This is because they are taken from iNaturalist. All additions and changes are made there and picked up using the iNaturalist API, on a daily basis. Therefore, I only set up route for "show" and "index".
Route::resource('/observations', ObservationController::class)->only([
'index', 'show'
]);
For Taxa, Checklists and Articles I have the equivalent route but add additional routes for "create", "edit", "store' and "save".
Route::resource('/taxa', TaxonController::class)->only([
'create', 'edit', 'store', 'save'
])->middleware('auth');
These use middleware('auth')
to enforce access policies that I have set up for each model. I won't overload this article with too much detail about how policies are created but, if you want to know more, you can check out the Laravel Documentation. In very simple terms, a policy class contains a method for each possible action on a model or collection of models. Each collection related function takes a single parameter of the current user and must return true
or false
to indicate whether or not that user has the ability to perform that action. Model-specific functions also take the model as a parameter, to allow fine-grained access control rules to be defined. Policies may optionally also contain a before()
method, which may be used to override the specific action methods. I will explain with an example. As mentioned above, some of my models can be created and edited but I didn't mention that this is restricted only to admin users (currently only me). This is achieved very simply by ignoring the parameters passed to the "action" methods, and returning true
from the methods that I want all users to be able to access and false
from all the others. I then have a before()
method to override this by returning true
if the user has the 'admin' role.
public function before(User $user, $ability)
{
if ($user->role === 'admin') {
return true;
}
}
For Article Categories and Checklist Categories only admin users can perform all actions. So the routes are set up simply like this.
Route::resource('/article-categories', ArticleCategoryController::class)->middleware('auth');
The policy returns false
from all action methods and has the same before()
method as above.
I have one other "normal" route definition for the search results page of the Taxa search.
Route::get('/taxa/search', [TaxonController::class, 'search'])->name('taxa.search');
Articles, Categories and Taxa by ID or slug
By default, Laravel routes to specific model pages are based on the ID of the model. One of the great features of Laravel is the automatic dependency injection. In this case, this means that Laravel will automatically retrieve the model based on the ID and pass it into the controller method that handles the route. This is referred to as "model binding". Another great feature of Laravel is that it provides easy methods to override or extend the default behaviour. Model binding can be overridden in the boot()
method of the App\Providers\RouteServiceProvider
class. To allow articles to be accessed via there ID or slug, I simple add a routing binding like this.
public function boot()
{
Route::bind('article', function ($value) {
return Article::where('id', $value)
->orWhere('slug', $value)
->first();
});
}
The exact same approach is used for Checklists and Taxa. Incidentally, my taxa slugs are made up from their id and scientific name. They just create a more meaningful URL.
Articles by Category/Slug
Although all of my article slugs are unique, and therefore suitable for links on their own, adding the slug from the category into the URL makes it far more meaningful URL. For example, this article's URL ('tall-stack/merging-routes') conveys a lot more information about the topic than only 'merging-routes'. To implement this, I add the following to the routes\web.php
file.
/* Route articles based on {category slug}/{article slug} */
Route::get('/articles/{category_slug}/{article_slug}', function ($category_slug, $article_slug) {
$category = ArticleCategory::where('slug', $category_slug)->first();
if (empty($category)) {
abort(404, "Article not found");
}
$article = Article::where('article_category_id', $category->id)->where('slug', $article_slug)->first();
if (empty($article)) {
abort(404, "Article not found");
}
return app()->make('App\Http\Controllers\ArticleController')->callAction('show', ['article' => $article]);
});
I first checks that the category slug matches one of the article categories and then the the article slug matches an article. I then uses the application container to make
an ArticleController
instance so we can call the show()
method for the article. The main benefit of this (e.g. versus a redirect) is that the URL remains unchanged.
Redirecting obsolete taxa
In my system, I have the concept of "empty" taxa. An empty taxon is one that has no child taxon and no directly related observations. These can occur due to changes in iNaturalist, either an observation gets re-identified as a different taxon or the taxonomy changes, making the origin taxon redundant. When I bring in data from iNaturalist, I never delete taxa. Instead, my daily job outputs a list of empty taxa. I can then edit each empty taxon and indicate which other taxon supersedes it. The origin taxon is then move to my list of obsolete taxa. I still want links to obsolete taxa to work but to redirect to the superseding taxa. I implemented this using an exception handler. In Laravel, exception handling code is place in the render()
method of the App\Exceptions\Handler
class. The render()
method receives the Throwable
as a parameter, and I am applying my redirection code when it is an instance of ModelNotFoundException
.
if ($exception instanceof ModelNotFoundException) {
$path_parts = explode('/', $request->path());
if ('taxa' === $path_parts[0]) {
$obsoleteTaxon = ObsoleteTaxon::find($path_parts[1]);
if (empty($obsoleteTaxon) || empty($obsoleteTaxon->superseded_by_id)) {
abort(404, "Taxon not found");
}
return redirect("/taxa/{$obsoleteTaxon->superseded_by_id}", 301);
}
}
First I turn the request path into an array
so it is easier to handle. If I am dealing with a taxa request, the second part of the request ($path_parts[1]
) should be a taxon ID. I check to see if there is an ObsoleteTaxon
model with that ID. If so, we redirect to the taxa page for the superseded_by_id
of the ObsoleteTaxon
. Otherwise we abort with a 404 code.
It is possible (but probably unlikely) that the taxon that superseded an obsolete taxon will itself become obsolete. The code above should handle that because the redirect would result in another ModelNotFoundException
, which would be caught here and redirect to the latest taxon in any chain of supersedence.
Taxa by their old links
In my old system taxa were accessed by links of the following format:
taxa/{kingdom}/{taxon rank}/{scientific name}/
Now, I simply use the taxon ID. This is partly due to the fact that I am now using the iNaturalist ID as the ID in my system, which makes it much easier to cross-reference the two. Anyway, I have used my old links, for example, as answer to Facebook questions, and I know some other sites that link to mine using the old links, so I wanted them to continue to work.
Because the old links are of a very different structure to the new links, it is easy to deal with them by defining a route in route\web.php
.
/* Reroute old-system taxon URLs */
Route::get('/taxa/{kingdom}/{rank}/{scientific_name}', function ($kingdom, $rank, $scientific_name) {
$scientific_name = str_replace('-', ' ', $scientific_name);
$taxon = Taxon::where('scientific_name', $scientific_name)->where('rank', $rank)->where('path', 'LIKE', $kingdom . '%')->first();
if (empty($taxon)) {
/* Try to find an obsolete taxon */
$obsoleteTaxon = ObsoleteTaxon::where('scientific_name', $scientific_name)->where('rank', $rank)->where('path', 'LIKE', $kingdom . '%')->first();
if (empty($obsoleteTaxon) || empty($obsoleteTaxon->superseded_by_id)) {
abort(404, "Taxon not found");
} else {
return redirect("/taxa/{$obsoleteTaxon->superseded_by_id}", 301);
}
} else {
return app()->make('App\Http\Controllers\TaxonController')->callAction('show', ['taxon' => $taxon]);
}
});
First I try to find the Taxon
model that matches the kingdon, rank and scientific name in the URL. If I find one, I use a TaxonController
instance (obtained via the application container, as explained for articles above) to show the found taxon. If not, I try to find an ObsoleteTaxon
model and redirect to its superseded_by_id
(as explained above).
Checklists by old links
In my old system checklists were accessed by links using the scientific name of the root taxon of the checklist. For example, my checklist of butterflies was accessed with:
checklists/papilionoidea/
Now, I simply use the checklist ID. The main reason for this is that, while checklists are always based on a root taxon, they may also exclude taxa, which then renders inaccurate a link based on the root taxon. I will probably implement slugs for checklists, as I have for articles, but, for now, I just support ID links.
As with obsolete taxa links, the routing of old checklist links is handled in the render()
method of the App\Exceptions\Handler
class.
if ($exception instanceof ModelNotFoundException) {
$path_parts = explode('/', $request->path());
<-- The code for obselete taxa (see above) -->
if ('checklists' === $path_parts[0]) {
/* If the model identifier is numeric, the checklist doesn't exist */
if (is_numeric($path_parts[1])) {
abort(404, "Checklist not found");
}
/* Try to find the taxon based on its scientific name */
$taxon = Taxon::where('scientific_name', $path_parts[1])->first();
if (empty($taxon)) {
/* Try to find an obsolete taxon with the scientific name */
$obsoleteTaxon = ObsoleteTaxon::where('scientific_name', $path_parts[1])->first();
if (empty($obsoleteTaxon) || empty($obsoleteTaxon->superseded_by_id)) {
abort(404, "Checklist not found");
}
$taxon_id = $obsoleteTaxon->superseded_by_id;
} else {
$taxon_id = $taxon->id;
}
$checklist = Checklist::where('taxon_id', $taxon_id)->first();
if (empty($checklist)) {
/* Try to find an obsolete taxon */
$obsoleteTaxon = ObsoleteTaxon::find($taxon_id);
/* Try to find the checklist that references the new taxon */
$checklist = Checklist::where('taxon_id', $obsoleteTaxon->superseded_by_id)->first();
if (empty($checklist)) {
abort(404, "Checklist not found");
}
}
return redirect("/checklists/{$checklist->id}", 301);
}
}
Having determined I am dealing with a checklist request, I check if the model identifier is numeric. If so, I know it is not an old style link and I abort. I then try to to find the Taxon
model with the scientific name from the request, so we can use its ID to find the checklist. If I don't find a Taxon
model, I try to find an ObsoleteTaxon
model with the scientific name, so we can use its superseded_by_id
as the taxon ID to find the checklist. I then try to find the Checklist
using the taxon ID. If not, I try (again) to find an ObsoleteTaxon
but this time using the taxon ID. Basically, I try my hardest to find a matching checklist! In this case, I use permanent redirect because this process is potentially far less efficient than supporting the old style links for taxa.
Wrapping Up
All I really want to say in conclusion is that in Laravel routing, including authenticated routes, is extremely easy to set up, and to override in special cases. The most complicated part of all of the above is handling obsolete taxa.