Back to homepage

Defining Scramble routes using PHP attributes

At my job we have several different services (all hail Galactus) that need to communicate with each other, and that means that API documentation is key.

But documentation takes time to write, so I had to find a way to make it hassle-free (automated 👀).

Automated API documentation for Laravel

Scramble to the rescue! Scramble is an amazing OpenAPI documentation generator for Laravel.

The documentation is generated automatically; you don't even have to write PHPDoc annotations, which has the added bonus of the documentation always being up to date.

Setting up Scramble is super easy, but I had a small issue with telling it which endpoints to generate documentation for.

By default Scramble will generate documentation for all routes starting with api, but in my case I wanted the documentation to be opt-in per route / action.

Now, you could of course just create a hardcoded list of the routes you want to include manually, but if you think that solution is tedious you should keep on reading. 😎

Making opt-in Scramble routes with PHP attributes

Scramble has made it easy to define your own route resolver:

<?php

use Illuminate\Support\Str;
use Dedoc\Scramble\Scramble;
use Illuminate\Routing\Route;

public function boot(): void
{
    Scramble::routes(function (Route $route) {
        return Str::startsWith($route->uri, 'api/');
    });
}

By combining this and the new attributes introduced in PHP 8, I came up with a solution to my opt-in problem.

Creating GenerateApiDocs attribute

First I created an attribute class that I could use for the controller actions (endpoints) I wanted to document:

<?php

namespace App\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class GenerateApiDocs
{
}

Since I only want this attribute to be assigned to methods, I use target specification (Attribute::TARGET_METHOD) in the first argument passed to the #[Attribute] declaration.

Then I added the attribute to all the actions I wanted to document:

<?php

use App\Attribute\GenerateApiDocs;

class SomeControllerAction
{
	#[GenerateApiDocs]
	public function __invoke(Request $request)
	{
        $validated = $request->validate(['name' => 'required|string']);
        
        return response()->json(['answer' => "Hello {$validated['name']}!"]])
    }
}

Updating the route resolver to use PHP reflection to detect the GenerateApiDocs attribute

The final and most important piece of the puzzle is updating the route resolver.

My goal is to check each route and see if it's using the GenerateApiDocs attribute, and to do that I'm using PHP's reflection API, more specifically the ReflectionMethod class.

With this we can retrieve various information about a method, including which attributes it has.

In Laravel, the method for the route can be retrieved using $route->getActionMethod(), which can be either a callable class (also called Single Action Controllers in Laravel) or a more "traditional" controller method, where you have multiple methods in a single class.

To distinguish between these two types of controllers I'm doing a simple check on $route->getActionMethod() against $route->getControllerClass(). If they are identical it means that it's a callable class, and the method is actually __invoke. Otherwise the method will just be $route->getActionMethod().

With the actual method name found out, the only thing left to do is create an introspection on the method, which can be done with PHP's ReflectionMethod class, and get the method's attributes.

You can view the full implementation below:

<?php

use Illuminate\Support\Str;
use Dedoc\Scramble\Scramble;
use Illuminate\Routing\Route;
use App\Attribute\GenerateApiDocs;

public function boot(): void
{
    Scramble::routes(function (Route $route) {
        // We still want to only document routes starting with api
        if (!Str::startsWith($route->uri, 'api/')) {
            return false;
        }

        // If the route does not have a controller we exclude the route
        if ($route->getController() === null) {
            return false;
        }

        // We get the method for the route so we can introspect it with reflection API
        $method = $route->getActionMethod() === $route->getControllerClass() ? 
            '__invoke' : 
            $route->getActionMethod();
        
        // If the route method has our GenerateApiDocs attribute we return true
        $attributes = (new ReflectionMethod($route->getControllerClass(), $method))->getAttributes(GenerateApiDocs::class);
        return \count($attributes) > 0;
    });
}

And that's it! With this setup our API documentation is fully automated and we have full control over which routes get added to the documentation.

Thanks for reading and much love to Scramble for saving me from having to write API documentation. 🙏