Hacked The Laravel Artisan Route Command To Enforce Route Naming Conventions
Last updated on

Have you ever tried using a piece of functionality from the Laravel framework in a way it’s not supposed to be used? Well, I did. Actually, I tried, and I succeeded. Not long ago, I wrote an article on architectural testing using PestPHP and its benefits within the Laravel ecosystem. That article was mostly focused on enforcing clean controller design using architectural testing.
This time, I had a different idea. I wanted to validate that every named route must follow the resourceful naming convention.
What is a Laravel Resource Controller?
A Laravel resource controller is a convenient way to group typical "CRUD" (Create, Read, Update, Delete) routes behind a single controller class. Laravel automatically assigns standard routes for actions like listing records, showing a single record, storing new entries, updating, and deleting. You don’t need to define each one manually.
According to the Laravel documentation, a resource controller maps the following actions:
Verb | URI | Action | Route Name |
---|---|---|---|
GET | /photos | index | photos.index |
GET | /photos/create | create | photos.create |
POST | /photos | store | photos.store |
GET | /photos/{photo} | show | photos.show |
GET | /photos/{photo}/edit | edit | photos.edit |
PUT/PATCH | /photos/{photo} | update | photos.update |
DELETE | /photos/{photo} | destroy | photos.destroy |
As you can see, the route names end in either index
, create
, store
, show
, edit
, update
, or destroy
. I wanted to ensure this convention was followed throughout the project. So, I started writing a custom expectation.
At first, I used Illuminate\Support\Facades\Route
, which provides the static method getRoutes
.
1 2// vendor/laravel/framework/src/Illuminate/Support/Facades/Route.php3 4/**5* @method static \Illuminate\Routing\RouteCollectionInterface getRoutes()6**/
This method returns an instance that implements RouteCollectionInterface
, which in turn offers another getRoutes
method:
1 2// vendor/laravel/framework/src/Illuminate/Routing/RouteCollectionInterface.php3 4/**5 * Get all of the routes in the collection.6 *7 * @return \Illuminate\Routing\Route[]8*/9 public function getRoutes();
That method returns an array of \Illuminate\Routing\Route[]
. Once I had that, the rest was simple: traverse the routes, check their names, and if any didn't follow the convention, fail the expectation.
It worked like a charm and gave the expected output. I joined all the invalid route names into a string and displayed it to the output stream. However, I wasn't happy, or you could say, I wasn't fully satisfied. I thought: would it be possible to fetch the routes through the Artisan route:list
command instead?
The Artisan command already comes with built-in functionality, which would make my work easier and cleaner. Plus, unlike my initial approach, I wouldn't need to add custom logic for excluding vendor-specific routes or formatting the display output.
Exploring the Artisan Route Command Internals
So I started digging into the internals of the route:list
command and discovered some functions that could help me with my goal:
1// vendor/laravel/framework/src/Illuminate/Foundation/Console/RouteListCommand.php 2/** 3 * Determine if the route has been defined outside of the application. 4 * 5 * @param \Illuminate\Routing\Route $route 6 * @return bool 7 */ 8protected function isVendorRoute(Route $route) 9{10 if ($route->action['uses'] instanceof Closure) {11 $path = (new ReflectionFunction($route->action['uses']))12 ->getFileName();13 } elseif (is_string($route->action['uses']) &&14 str_contains($route->action['uses'], 'SerializableClosure')) {15 return false;16 } elseif (is_string($route->action['uses'])) {17 if ($this->isFrameworkController($route)) {18 return false;19 }20 21 $path = (new ReflectionClass($route->getControllerClass()))22 ->getFileName();23 } else {24 return false;25 }26 27 return str_starts_with($path, base_path('vendor'));28}29 30/**31 * Convert the given routes to regular CLI output.32 *33 * @param \Illuminate\Support\Collection $routes34 * @return array35 */36protected function forCli($routes)37{38 $routes = $routes->map(39 fn ($route) => array_merge($route, [40 'action' => $this->formatActionForCli($route),41 'method' => $route['method'] == 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS' ? 'ANY' : $route['method'],42 'uri' => $route['domain'] ? ($route['domain'].'/'.ltrim($route['uri'], '/')) : $route['uri'],43 ]),44 );45 46 $maxMethod = mb_strlen($routes->max('method'));47 48 $terminalWidth = $this->getTerminalWidth();49 50 $routeCount = $this->determineRouteCountOutput($routes, $terminalWidth);51 52 return $routes->map(function ($route) use ($maxMethod, $terminalWidth) {53 [54 'action' => $action,55 'domain' => $domain,56 'method' => $method,57 'middleware' => $middleware,58 'uri' => $uri,59 ] = $route;60 61 $middleware = (new Stringable($middleware))->explode("\n")->filter()->whenNotEmpty(62 fn ($collection) => $collection->map(63 fn ($middleware) => sprintf(' %s⇂ %s', str_repeat(' ', $maxMethod), $middleware)64 )65 )->implode("\n");66 67 $spaces = str_repeat(' ', max($maxMethod + 6 - mb_strlen($method), 0));68 69 $dots = str_repeat('.', max(70 $terminalWidth - mb_strlen($method.$spaces.$uri.$action) - 6 - ($action ? 1 : 0), 071 ));72 73 $dots = empty($dots) ? $dots : " $dots";74 75 if ($action && ! $this->output->isVerbose() && mb_strlen($method.$spaces.$uri.$action.$dots) > ($terminalWidth - 6)) {76 $action = substr($action, 0, $terminalWidth - 7 - mb_strlen($method.$spaces.$uri.$dots)).'…';77 }78 79 $method = (new Stringable($method))->explode('|')->map(80 fn ($method) => sprintf('<fg=%s>%s</>', $this->verbColors[$method] ?? 'default', $method),81 )->implode('<fg=#6C7280>|</>');82 83 return [sprintf(84 ' <fg=white;options=bold>%s</> %s<fg=white>%s</><fg=#6C7280>%s %s</>',85 $method,86 $spaces,87 preg_replace('#({[^}]+})#', '<fg=yellow>$1</>', $uri),88 $dots,89 str_replace(' ', ' › ', $action ?? ''),90 ), $this->output->isVerbose() && ! empty($middleware) ? "<fg=#6C7280>$middleware</>" : null];91 })92 ->flatten()93 ->filter()94 ->prepend('')95 ->push('')->push($routeCount)->push('')96 ->toArray();97}
isVendorRoute()
excludes vendor-specific routes.forCLI()
gives a nicely formatted output, ready for the terminal.
Perfect, right? Almost. The problem was, both of these functions are marked as protected
.
What is protected
in PHP?
The protected
access modifier in PHP means that the property or method can only be accessed within the class itself or in child classes (subclasses). It’s not accessible from outside the class directly.
Here’s a quick example:
1class Example {2 protected function greet() {3 return "Hello from inside!";4 }5}6 7$ex = new Example();8// This will throw an error9echo $ex->greet(); // Fatal error: Uncaught Error
As you can see, calling the greet()
method directly from outside the class won't work.
As you know, I can’t access anything marked as protected
directly, and I can’t override the core Artisan command either. But luckily, PHP offers something powerful called ReflectionClass
.
What is ReflectionClass
in PHP?
ReflectionClass
is a part of PHP’s Reflection API, which lets you inspect classes, methods, properties, and even invoke methods that are otherwise inaccessible (like protected or private ones). It’s commonly used in testing, debugging, and advanced use cases like this one.
Here’s a basic example:
1class Secret { 2 protected function reveal() { 3 return "Top secret!"; 4 } 5} 6 7$refClass = new ReflectionClass(Secret::class); 8$method = $refClass->getMethod('reveal'); 9$method->setAccessible(true);10 11$instance = new Secret();12echo $method->invoke($instance); // Outputs: Top secret!
Using this, I was able to tap into the protected methods of the route command and do exactly what I needed, without rewriting or duplicating logic.
So by now, you probably get the idea that came to my mind. I used ReflectionClass
, changed the access modifiers for both of the functions, and used them. And guess what? It worked exactly as I hoped.
Here’s the final expectation:
1expect()->extend('toFollowLaravelNamingConvention', function () { 2 /** 3 * @var RouteListCommand $routeListCommand 4 */ 5 $routeListCommandClass = app()->make(RouteListCommand::class); 6 $reflection = new ReflectionClass($routeListCommandClass); 7 8 $getRoutes = $reflection->getMethod('getRoutes'); 9 $getRoutes->setAccessible(true);10 11 $isVendorRoute = $reflection->getMethod('isVendorRoute');12 $isVendorRoute->setAccessible(true);13 14 $forCLI = $reflection->getMethod('forCLI');15 $forCLI->setAccessible(true);16 17 $reflection->getProperty('laravel')->setValue($routeListCommandClass, app());18 19 $output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, true);20 $reflection->getProperty('output')->setValue($routeListCommandClass, $output);21 22 $routes = collect(Route::getRoutes()->getRoutes())23 // Exclude nameless routes24 ->filter(fn($route) => $route->getName())25 // Exclude vendor specific routes26 ->filter(27 fn($route) =>28 !$isVendorRoute29 ->invoke(30 $routeListCommandClass,31 $route32 )33 )34 // ensure name ends with allowed suffixes35 ->filter(36 function ($route) {37 $parts = explode('.', $route->getName());38 $suffix = end($parts);39 return !in_array(40 $suffix,41 [42 'index',43 'create',44 'store',45 'show',46 'edit',47 'update',48 'destroy',49 ]50 );51 }52 )53 // transform route object to RouteListCommand compatible array54 ->map(fn($route) => [55 'domain' => $route->domain(),56 'method' => implode('|', $route->methods()),57 'uri' => $route->uri(),58 'name' => $route->getName(),59 'action' => ltrim($route->getActionName(), '\\'),60 'middleware' => "",61 'vendor' => false,62 ]);63 /**64 * If routes array is not empty, there're some routes not following convention,65 * fail expectation with developer friendly message.66 */67 if (!$routes->isEmpty()) {68 test()->fail(implode("\n", array_merge(69 ["Following routes are not following Laravel resourceful naming conventions"],70 $forCLI->invoke($routeListCommandClass, $routes)71 )));72 }73 74 return true;75});
And here’s the assertion output from the expectation. Isn't it marvelous?
This approach not only saved me from writing redundant logic but also allowed me to take full advantage of Laravel's built-in features. Sure, it's a bit of a hacky trick, but sometimes bending the rules a little gives you cleaner, more maintainable code.
If you found this approach insightful or learned something new about bending Laravel to your will, I’d love to hear your thoughts. This is just one example of how curiosity and a bit of unconventional thinking can help you get the most out of your tools without bloating your codebase. I'm always experimenting with better ways to write clean, maintainable software that fits real-world needs. If you're looking for someone who can bring that kind of mindset to your projects, feel free to check out my Upwork profile and let’s work together.