Fully automated API documentation generation for Laravel

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 /places
GET /bookings
POST /bookings
PUT /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 /bookings
POST /bookings
PUT /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.

Terminal window
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:

Terminal window
composer require dedoc/scramble

Now visit /docs/api page.

Basic documentation

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:

Correct paginated response documentation

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:

Endpoint with title and description

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:

Authentication

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:

Grouping endpoints

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:

Terminal window
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:

config/scramble.php
<?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.

config/scramble.php
<?php
return [
// ...
'info' => [
// ...
'description' => '',
'description' => file_get_contents(__DIR__.'/../README.md'),
// ...
],
// ...
];

And, here is the result:

Custom description page

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:

app/Providers/AppServiceProvider.php
<?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!

Scramble PRO
Comprehensive API documentation generation for Spatie’s Laravel Data and Laravel Query Builder.