Fully automated API documentation generation for Laravel
August 8, 2024
Learn how to get fully automated API documentation (without PHPDoc annotations required) for your Laravel app with Scramble.
Recently I was listening to Laravel podcast, where Matt and Taylor discussed API documentation generation. Matt said that there are 2 ways of generating API documentation: you either write PHPDoc annotations in your code which makes it cluttered, or you write and maintain API spec file manually yourself which is tedious and may result in outdated documentation.
I’m writing this post to show that there is another way to have API documentation: using static code analysis. This allows having fully automated API documentation (without PHPDoc annotations required), always up-to-date with your codebase.
Meet Scramble
Let me introduce you Scramble: the library for API documentation generation for Laravel which doesn’t require you to write any PHPDoc annotations.
Scramble works by using static code analysis. It infers the types across the codebase and based on that it can tell how requests or responses look like.
The API we’ll make a documentation for
To keep this post short, this API is a very simple – this is a small booking management system where user can manage their bookings.
So here is the API we’ll document:
Base URL: https://booking.test/api
Endpoints:GET /placesGET /bookingsPOST /bookingsPUT /bookings/{booking}GET /bookings/{booking}DELETE /bookings/{booking}
So before describing how to document the API and introducing you to Scramble, I’d like to describe the API implementation first, so we’re on the same page about what it does.
Places API
GET /places
Places API allows to get the list of places available for bookings. We want to allow users to search the places using a bunch of filters, and give them ability to sort them.
We will have Place
model and invokable PlaceController
:
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;use App\Http\Resources\PlaceResource;use App\Models\Place;use Illuminate\Http\Request;use Illuminate\Validation\Rule;
class PlacesController extends Controller{ public function __invoke(Request $request) { $request->validate([ 'price_from' => ['int'], 'price_to' => ['int'], 'sorts' => ['array'], 'sorts.*.field' => [Rule::in(['price'])], 'sorts.*.direction' => [Rule::in(['asc', 'desc'])], ]);
$placesQuery = Place::query() ->when($request->get('price_from'), fn ($q, $priceFrom) => $q->where('price', '>=', $priceFrom)) ->when($request->get('price_to'), fn ($q, $priceTo) => $q->where('price', '<=', $priceTo));
foreach ($request->collect('sorts') as $sort) { $placesQuery->orderBy($sort['field'], $sort['direction']); }
return PlaceResource::collection($placesQuery->paginate($request->integer('per_page', 15))); }}
Our place resource will be a simple one for now, showing basic attributes of the place.
namespace App\Http\Resources;
use Illuminate\Http\Request;use Illuminate\Http\Resources\Json\JsonResource;
class PlaceResource extends JsonResource{ public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'price' => $this->price, ]; }}
Bookings API
GET /bookingsPOST /bookingsPUT /bookings/{booking}GET /bookings/{booking}DELETE /bookings/{booking}
For bookings, we’ll have a Booking
model and BookingController
. For now it’ll be very simple controller, allowing users to list their bookings, create a booking, update a booking, or delete it.
When user creates or updates a booking, we’d want to know it the given time is available for a booking and if not – the API will reply with clear message about the issue of time is being not available.
So here is the controller:
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;use App\Http\Resources\BookingResource;use App\Models\Booking;use App\Models\Place;use Illuminate\Http\Request;
class BookingsController extends Controller{ public function index(Request $request) { return BookingResource::collection($request->user()->bookings); }
public function store(Request $request) { $request->validate([ 'place_id' => ['required', 'exists:places,id'], 'date' => ['required', 'date'], ]);
$place = Place::find($request->get('place_id')); if (! $place->available($request->date('date'))) { return response()->json(['message' => 'Place is not available at the given date'], 409); }
$booking = $request->user()->bookings()->create([ 'place_id' => $request->get('place_id'), 'date' => $request->get('date'), ]);
return BookingResource::make($booking); }
public function update(Request $request, Booking $booking) { $data = $request->validate([ 'date' => ['required', 'date'], ]);
if (! $booking->place->available($request->date('date'))) { return response()->json(['message' => 'Place is not available at the given date'], 409); }
$booking->update($data);
return BookingResource::make($booking); }
public function show(Booking $booking) { return BookingResource::make($booking); }
public function destroy(Booking $booking) { $booking->delete();
return response()->noContent(); }}
BookingResource
is going to be as simple as possible for now as well:
namespace App\Http\Resources;
use Illuminate\Http\Request;use Illuminate\Http\Resources\Json\JsonResource;
class BookingResource extends JsonResource{ public function toArray(Request $request): array { return [ 'id' => $this->id, 'place_id' => $this->place_id, 'user_id' => $this->user_id, 'date' => $this->date, ]; }}
Registering routes
Now, we’ll enable API routing using this command. This will save us few keystrokes and create API routes file with the setup we need. For auth we’ll use Sanctum, which also will be added when installing the API.
php artisan install:api
Now, we’ll define our endpoints in routes/api.php
use App\Http\Controllers\Api\BookingsController;use App\Http\Controllers\Api\PlacesController;use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () { Route::post('places', PlacesController::class); Route::apiResource('bookings', BookingsController::class);});
Let’s now check the list of resulting API routes by running route:list
command.
$ php artisan route:list --except-vendor
GET|HEAD / .................................................................... GET|HEAD api/bookings ........... bookings.index › Api\BookingsController@index POST api/bookings ........... bookings.store › Api\BookingsController@store GET|HEAD api/bookings/{booking} ... bookings.show › Api\BookingsController@show PUT|PATCH api/bookings/{booking} bookings.update › Api\BookingsController@update DELETE api/bookings/{booking} bookings.destroy › Api\BookingsController@dest… GET api/places ...................................... Api\PlacesController
Showing [7] routes
Okay, so at this point we have few endpoints that implement our basic logic for booking API. Let’s document it so it can be consumed.
Documenting the API
First of all, install Scramble:
composer require dedoc/scramble
Now visit /docs/api
page.
Well, this is it! Our API documentation is 90% ready without any manual PHPDoc annotations!
Let’s push it to 100% by making sure everything is correct and adding some manual text descriptions where needed.
But before that, I’d like to reveal this magic so we’re on the same page why it worked.
How it works, in a nutshell
First of all, Scramble collects routes that are considered as API routes. By default these are routes which URI start with api/
prefix.
Then, once routes are collected, Scramble documents each route by documenting request body/query params, path params.
After the request part documented, Scramble’s type inference system kicks in: for every route’s controller method, Scramble analyzes what type is returned from it using static code analysis. Then, the returned types become documented as responses.
Ensuring response are correct
While Scramble has inferred most of types, there is a paginated response (GET api/places
) which need a manual annotation (relevant for 0.11.*
, but most likely will be improved starting from 0.12.*
).
So here is GET api/places
actual successful response:
{ "data": [ { "id": 3282, "name": "Cozy Amsterdam Light Studio", "price": 180 }, ... ], "links": { "first": "http://demo-scramble.test/api/places?page=1", "last": "http://demo-scramble.test/api/places?page=1", "prev": null, "next": null }, "meta": { "current_page": 1, "from": 1, "last_page": 1, "links": [...], "path": "http://demo-scramble.test/api/places", "per_page": 15, "to": 10, "total": 10 }}
To show it correctly in the documentation, we need to add manual annotation for endpoint’s controller method:
use Illuminate\Validation\Rule;use Illuminate\Pagination\LengthAwarePaginator;use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class PlacesController extends Controller{ /** * @response AnonymousResourceCollection<LengthAwarePaginator<PlaceResource>> */ public function __invoke(Request $request) { $request->validate([ 'price_from' => ['int'], 'price_to' => ['int'],
Now Scramble generates the documentation for this endpoint’s response taking into account this endpoint returns a paginated set of PlaceResource
:
Adding titles and descriptions to our endpoints
To make it simpler for users to consume the documentation we may also add titles and descriptions to our endpoints.
To do so, we simply add the text to the PHPDoc comment of the method.
class PlacesController extends Controller{ /** * List places. * * List all places that can be booked by the user. The user can filter the list by price range and sort the results. * * @response AnonymousResourceCollection<LengthAwarePaginator<PlaceResource>> */ public function __invoke(Request $request) { $request->validate([
And the resulting documentation:
Enhancing the documentation with extra features
Now, our documentation is almost ready. Let’s improve it further before calling it a day.
Adding authentication details
To add authentication details, we need to describe how our authentication works in terms of OpenAPI standard. To do so, we need to add a security scheme.
Sanctum auth’s API token is provided via Authentication
header.
To add this information to the documentation, we’ll define the default security scheme:
<?php
namespace App\Providers;
use Dedoc\Scramble\Scramble;use Dedoc\Scramble\Support\Generator\OpenApi;use Dedoc\Scramble\Support\Generator\SecurityScheme;use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider{ // ... public function boot(): void { Scramble::afterOpenApiGenerated(function (OpenApi $openApi) { $openApi->secure( SecurityScheme::http('bearer'), ); }); }}
Now our documentation clearly communicates to consumers about how to authenticate:
You can learn more about the security documentation in the documentation: https://scramble.dedoc.co/usage/security
Grouping and organizing endpoints
Our endpoints are already grouped in a way that makes sense because to the controller base name used as a group for the endpoints.
In case we want to override that, we can use @tags
annotation on controller’s level to name a group of endpoints in a sensible way.
So to group all our endpoints under the same category called “Bookings management”, we simply add the annotation on the controller level:
/** * @tags Bookings management */class PlacesController extends Controller{
/** * @tags Bookings management */class BookingsController extends Controller{
And now we have all these endpoints in the same group:
While this definitely makes sense in some scenarios when grouping by controller’s name does not work, for our API the defaults perfectly makes sense, so we’ll stick with that.
Adding logo, title, API overview page
Finally, we’d want to finish our documentation by adding title, logo, and API overview page. To do so, publish the config file:
php artisan vendor:publish --provider="Dedoc\Scramble\ScrambleServiceProvider" --tag="scramble-config"
To specify the title and logo, we’ll define the scramble.ui.title
, and scramble.ui.logo
config keys:
<?php
return [ // ...
'ui' => [ // ... 'title' => null, 'title' => 'Bookly', // ... 'logo' => '', 'logo' => '/images/logo.svg', // ... ],
// ...];
For logo, I’ve created an SVG file in public/images
folder with an icon from Heroicons pack.
Now, to add an API overview page, we’ll define scramble.info.description
key. You can use string with markdown syntax here, but I like to get the content of some file, to keep config file short and sweet and edit the content in a markdown’s file.
<?php
return [ // ...
'info' => [ // ... 'description' => '', 'description' => file_get_contents(__DIR__.'/../README.md'), // ... ],
// ...];
And, here is the result:
Enabling access to documentation in production
By default, Scramble documentation will work in non-production environment only. To allow accessing documentation in production environment, you need to define viewApiDocs
gate – it describes the authorization behavior for production environment.
In our case we want to make our documentation fully public, so let’s do that. In a service provider we’ll define this gate:
<?php
namespace App\Providers;
class AppServiceProvider extends ServiceProvider{ // ... public function boot(): void { // ... Gate::define('viewApiDocs', function () { return true; }); }}
Now, our documentation will be available on docs/api
in production for everyone to browse.
Conclusion
So as you can see, there is a third way of having API documentation: relying on static code analysis. This approach not only saves time but also ensures that your documentation is always up-to-date with the latest code changes.
This post touches only basics. You can learn more about Scramble here: https://scramble.dedoc.co/
Happy coding!