How To Generate a OpenAPI for Laravel
You’re investing in your API, and that means finally creating an OpenAPI document that accurately describes your API. With the rise in popularity of API-first design some APIs might have declared their OpenAPI before writing the code, but for many the code-first workflow is still fundamental for older APIs. If you’re working with an existing Laravel application, you can generate a complete OpenAPI document directly from the API’s source code.
A few excellent tools have come and gone over the years, but these days Scribe
What is Scribe all about
Scribe is a robust documentation solution for PHP APIs. It helps you generate comprehensive human-readable documentation from your Laravel/Lumen/Dingo codebase, without needing to add docblocks or annotations for everything like other tools have required in the past.
Scribe introspects the API source code itself, and without AI fudging the results it will accurately turn routing, controllers, Eloquent models, and all sorts of code into the best and most accurate API descriptions possible. Then it can be exported as OpenAPI, or Postman collections (if you’re into that sort of thing.)
The first step is to install a package, and explore the options available.
composer require --dev knuckleswtf/scribeOnce installed, publish the package configuration to access the full variety of config options.
php artisan vendor:publish --tag=scribe-configThere are a lot of config options
php artisan scribe:generateThe command above will generate both HTML documentation and an OpenAPI specification file. By default, the OpenAPI document will be saved in storage/app/private/scribe/openapi.yaml, but the command will let you know exactly where it’s been saved.
openapi: 3.0.3
info:
title: 'Laravel API Documentation'
description: ''
version: 1.0.0
servers:
-
url: 'http://localhost'
tags:
-
name: Endpoints
description: ''
paths:
/api/health:
get:
summary: ''
operationId: getApiHealth
description: ''
responses:
200:
description: ''
content:
application/json:
schema:
type: object
properties:
status:
type: string
version:
type: string
timestamp:
type: string
tags:
- Endpoints
security: []
/api/drivers:
get:
summary: 'Display a listing of the resource.'
operationId: displayAListingOfTheResource
description: ''
responses:
200:
description: ''
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
code:
type: string
created_at:
type: string
updated_at:
type: string
meta:
type: object
properties:
count:
type: integer
tags:
- Endpoints
security: []
'/api/drivers/{id}':
get:
summary: 'Display the specified resource.'
operationId: displayTheSpecifiedResource
description: ''
responses:
200:
description: ''
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: integer
name:
type: string
code:
type: string
created_at:
type: string
updated_at:
type: string
tags:
- Endpoints
security: []
parameters:
-
in: path
name: id
description: 'The ID of the driver.'
required: true
schema:
type: integer
/api/circuits:
get:
summary: 'Display a listing of the resource.'
operationId: displayAListingOfTheResource
description: ''
responses:
200:
description: ''
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
location:
type: string
created_at:
type: string
updated_at:
type: string
meta:
type: object
properties:
count:
type: integer
tags:
- Endpoints
security: []
post:
summary: 'Store a newly created resource in storage.'
operationId: storeANewlyCreatedResourceInStorage
description: ''
responses: { }
tags:
- Endpoints
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: ''
nullable: false
location:
type: string
description: ''
nullable: false
required:
- name
- location
security: []
'/api/circuits/{id}':
get:
summary: 'Display the specified resource.'
operationId: displayTheSpecifiedResource
description: ''
responses:
200:
description: ''
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: integer
name:
type: string
location:
type: string
created_at:
type: string
updated_at:
type: string
tags:
- Endpoints
security: []
parameters:
-
in: path
name: id
description: 'The ID of the circuit.'
required: true
schema:
type: integer
/api/races:
get:
summary: 'Display a listing of the resource.'
operationId: displayAListingOfTheResource
description: ''
responses:
200:
description: ''
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
race_date:
type: string
season:
type: string
created_at:
type: string
updated_at:
type: string
links:
type: object
properties:
self:
type: string
circuit:
type: string
drivers:
type: string
meta:
type: object
properties:
count:
type: integer
tags:
- Endpoints
security: []
'/api/races/{id}':
get:
summary: 'Display the specified resource.'
operationId: displayTheSpecifiedResource
description: ''
responses:
200:
description: ''
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: integer
name:
type: string
race_date:
type: string
season:
type: string
created_at:
type: string
updated_at:
type: string
links:
type: object
properties:
self:
type: string
circuit:
type: string
drivers:
type: string
tags:
- Endpoints
security: []
parameters:
-
in: path
name: id
description: 'The ID of the race.'
example: 1
required: true
schema:
type: integerA surprisingly good start for something that’s had absolutely no work done so far. Beyond just outputting endpoints and models, Scribe was able to look through JSON resources (serializers) to figure out what the response payloads would look like, and describe them as JSON Schema objects.
Examples were also generated based on the data in the database, which is a great touch, but got a bit big for sharing in the above example. They are based on Laravel’s database seeders
The biggest shortfall is the lack of human-written descriptions. Not only are we missing a lot of the “why” and “how” in this sea of “what”, but we are also struggling with poor summaries for each operation which in turn is giving poor operationIds, and THAT will produce a bad SDK in Speakeasy.
Let’s look at some ways we can improve this output with quick config settings, and then by adding some attributes to the controllers to improve things further.
Configuring Scribe
Open the config/scribe.php file that was published earlier, and look for the following options:
// The HTML <title> for the generated documentation.
'title' => 'F1 Race API',
// A short description of your API. Will be included in the docs webpage, Postman collection and OpenAPI spec.
'description' => '',
// Text to place in the "Introduction" section, right after the `description`. Markdown and HTML are supported.
'intro_text' => <<<INTRO
This documentation aims to provide all the information you need to work with our API.
<aside>As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).</aside>
INTRO,Updating these options will help give some context to the API consumers about what this API is all about, which is the first and most fundamental point of any API documentation.
There are lots of other options available, but some of the most important ones to consider when generating an OpenAPI document are:
routes- this option allows us to configure how we want to detect the API routes. Tweak the prefix, and exclude any routes that should not show up in the documentation.auth- specify the API’s authentication mechanism. This will be used to describe thesecuritysection of the OpenAPI document.strategies- this is where we configure how Scribe will interact with our application to get the data needed to create the specification and documentation.
Poke around if its of interest, but for now let’s move onto improving the API description by learning a litte more about how Scribe is creating them.
How Scribe Works
Scribe scans your application routes to identify which endpoints should be described, based on your configuration. It then extracts metadata from the corresponding routes, such as route names, URI patterns, HTTP methods. It can do this by looking purely at the code, but extra information can be added using annotations and comments in the controller to expand on the “why” and “how” of the API.
Scribe then uses the extracted metadata to perform request simulation on your API. It captures the responses that come back, including: status codes, headers, and body content. All this then get packaged into an internal representation of your API, which is how the OpenAPI spec is created.
In the example above, only the 401 is being documented because Scribe hasn’t been configured with the proper authentication information, which makes it unable to access the proper response.
Getting to 200
Let’s modify the Laravel code to get some useful information about our 200 responses.
To achieve this, PHP Attributes
Adding tags
In OpenAPI, tags are used to group related operations together. Typically, a good way to use tags is to have one tag per “resource” and then associate all the relevant operations that access and modify that resourc together. We’ll add a group annotation to the top of the controller.
// !focus(1)
#[Group(name: 'Races', description: 'A series of endpoints that allow programatic access to managing F1 races.', authenticated: true)]
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private RaceRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(RaceResource::class, Race::class, collection: true)]
#[Endpoint(title: 'Browse Races', description: 'Browse through the F1 races for the season.')]
public function __invoke(Request $request): CollectionResponse
{
$races = $this->repository->forSeason(
season: $request->query('season', date('Y')),
);
return new CollectionResponse(
data: RaceResource::collection(
resource: $races->paginate(),
),
);
}
}Authenicate Scribe
Let’s next focus on the invoke method that is what will be used to generate the path information. We use #[Authenticated] to let Scribe know that this endpoint needs to be authenticated
<<<<<<< HEAD
#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
=======
// !focus(10)
#[Group(name: 'Races', description: 'A series of endpoints that allow programatic access to managing F1 races.', authenticated: true)]
>>>>>>> 70f8667 (docs: laravel guide update)
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private RaceRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(RaceResource::class, Race::class, collection: true)]
#[Endpoint(title: 'Browse Races', description: 'Browse through the F1 races for the season.')]
public function __invoke(Request $request): CollectionResponse
{
$races = $this->repository->forSeason(
season: $request->query('season', date('Y')),
);
return new CollectionResponse(
data: RaceResource::collection(
resource: $races->paginate(),
),
);
}
}Add Descriptions
Use #[Endpoint] to add additional information about this endpoint; describing what it’s function is.
<<<<<<< HEAD
#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
=======
// !focus(11)
#[Group(name: 'Races', description: 'A series of endpoints that allow programatic access to managing F1 races.', authenticated: true)]
>>>>>>> 70f8667 (docs: laravel guide update)
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private RaceRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(RaceResource::class, Race::class, collection: true)]
#[Endpoint(title: 'Browse Races', description: 'Browse through the F1 races for the season.')]
public function __invoke(Request $request): CollectionResponse
{
$races = $this->repository->forSeason(
season: $request->query('season', date('Y')),
);
return new CollectionResponse(
data: RaceResource::collection(
resource: $races->paginate(),
),
);
}
}Adding Responses
Finally, we want to add #[ResponseFromApiResource] to let Scribe know how this API should respond, passing through the resource class and the model itself so Scribe can make a request in the background and inspect the types on the response payload. Also, we pass the boolean flag for whether or not this response should return a collection or not.
<<<<<<< HEAD
#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
=======
// !focus(12)
#[Group(name: 'Races', description: 'A series of endpoints that allow programatic access to managing F1 races.', authenticated: true)]
>>>>>>> 70f8667 (docs: laravel guide update)
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private RaceRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(RaceResource::class, Race::class, collection: true)]
#[Endpoint(title: 'Browse Races', description: 'Browse through the F1 races for the season.')]
public function __invoke(Request $request): CollectionResponse
{
$races = $this->repository->forSeason(
season: $request->query('season', date('Y')),
);
return new CollectionResponse(
data: RaceResource::collection(
resource: $races->paginate(),
),
);
}
}Now let’s see the OpenAPI spec:
/api/races:
get:
summary: "Browse Races"
operationId: browseRaces
description: "Browse through the F1 races for the season."
responses:
200:
description: ""
content:
application/json:
schema:
type: object
example:
data:
- id: ""
type: races
attributes:
name: Monaco Grand Prix
race_date: '2024-05-26'
season: '2024'
circuit: Monte Carlo Circuit
winner: Max Verstappen
created:
human: null
timestamp: null
string: null
local: null
- id: ""
type: races
attributes:
name: British Grand Prix
race_date: '2024-07-07'
season: '2024'
circuit: Silverstone Circuit
winner: Lewis Hamilton
created:
human: null
timestamp: null
string: null
local: null
properties:
data:
type: array
example:
- id: ""
type: races
attributes:
name: Monaco Grand Prix
race_date: '2024-05-26'
season: '2024'
circuit: Monte Carlo Circuit
winner: Max Verstappen
created:
human: null
timestamp: null
string: null
local: null
- id: ""
type: races
attributes:
name: British Grand Prix
race_date: '2024-07-07'
season: '2024'
circuit: Silverstone Circuit
winner: Lewis Hamilton
winner: Lewis Hamilton
created:
human: null
timestamp: null
string: null
local: null
items:
type: object
properties:
id:
type: string
example: ""
type:
type: string
example: races
attributes:
type: object
properties:
name:
type: string
example: Monaco Grand Prix
race_date:
type: string
example: '2024-05-26'
season:
type: string
example: '2024'
circuit:
type: string
example: Monte Carlo Circuit
winner:
type: string
example: Max Verstappen
created:
type: object
properties:
human:
type: string
example: null
timestamp:
type: string
example: null
string:
type: string
example: null
local:
type: string
example: null
tags:
- "Races"Documenting Parameters
So far so good! However, this API example is limited. What if we add query parameters like filtering and sorting which we would likely want on a real API.
In terms of Laravel implementation, we recommend use the spatie/laravel-query-builder package to enable JSON:API style filtering on my API, as it ties directly into Eloquent ORM from the request parameters. Let’s start adding some filters.
Our controller code used our RaceRepository which just leverages Eloquent to query our database through a shared abstraction. However, we want to lean on the package by Spatie, which has a slightly different approach. Let’s rewrite this code to make it more flexible.
#[Authenticated]
#[Endpoint(title: 'Browse Races', description: 'Browse through the F1 races for the season.')]
#[ResponseFromApiResource(RaceResource::class, Race::class, collection: true)]
public function __invoke(Request $request): CollectionResponse
{
$races = QueryBuilder::for(
subject: $this->repository->forSeason(
season: $request->query('season', date('Y')),
),
)->allowedFilters(
filters: $this->repository->filters(),
)->allowedIncludes(
includes: $this->repository->includes(),
)->allowedSorts(
sorts: $this->repository->sort(),
)->getEloquentBuilder();
return new CollectionResponse(
data: RaceResource::collection(
resource: $races->paginate(),
),
);
}We use the QueryBuilder class from the package, to pass in the result of our repository call. The repository is just passing a pre-built query back, which we can use to paginate or extend as required. I prefer this approach as the sometimes you want to tie multiple methods together. You will see that I have 4 new methods that weren’t there before:
allowedFiltersallowedIncludesallowedSortsgetEloquentBuilder
The first three allow you to programmatically control what parts of the query parameters to use and which to ignore. The final one is to get back the eloquent query builder, that we want to use as we know the API for it. The package returns a custom query builder, which does not have all of the methods we may want. Let’s flesh out the filter, include, and sort method calls next.
Going back we add attributes that will be parsed - so that our OpenAPI spec is generated with all available options:
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private RaceRepository $repository,
) {
}
#[
Authenticated,
QueryParam(name: 'filter[season]', type: 'string', description: 'Filter the results by season year', required: false, example: 'filter[season]=2024'),
QueryParam(name: 'filter[circuit]', type: 'string', description: 'Filter the results by circuit name', required: false, example: 'filter[circuit]=Monaco'),
QueryParam(name: 'filter[winner]', type: 'string', description: 'Filter the results by the winning driver', required: false, example: 'filter[winner]=Verstappen'),
QueryParam(name: 'include', type: 'string', description: 'A comma separated list of relationships to side-load', required: false, example: 'include=circuit,drivers'),
QueryParam(name: 'sort', type: 'string', description: 'Sort the results based on either the race_date, or the season', required: false, example: 'sort=-race_date'),
ResponseFromApiResource(RaceResource::class, Race::class, collection: true),
Endpoint(title: 'Browse Races', description: 'Browse through the F1 races for the season.')
]
public function __invoke(Request $request): CollectionResponse
{
$races = $this->repository->forSeason(
season: $request->query('season', date('Y')),
);
return new CollectionResponse(
data: RaceResource::collection(
resource: $races->paginate(),
),
);
}
}Note
You may have noticed that the syntax has collapsed all the metadata into one attribute. It’s just a code style choice, there’s no change in functionality.
The result of the above will be the following inside your OpenAPI specification:
parameters:
- in: query
name: "filter[season]"
description: "Filter the results by season year"
example: "filter[season]=2024"
required: false
schema:
type: string
description: "Filter the results by season year"
example: "filter[season]=2024"
- in: query
name: "filter[circuit]"
description: "Filter the results by circuit name"
example: "filter[circuit]=Monaco"
required: false
schema:
type: string
description: "Filter the results by circuit name"
example: "filter[circuit]=Monaco"
- in: query
name: "filter[winner]"
description: "Filter the results by the winning driver"
example: "filter[winner]=Verstappen"
required: false
schema:
type: string
description: "Filter the results by the winning driver"
example: "filter[winner]=Verstappen"
- in: query
name: include
description: "A comma separated list of relationships to side-load"
example: "include=circuit,drivers"
required: false
schema:
type: string
description: "A comma separated list of relationships to side-load"
example: "include=circuit,drivers"
- in: query
name: sort
description: "Sort the results based on either the race_date, or the season"
example: sort=-race_date
required: false
schema:
type: string
description: "Sort the results based on either the race_date, or the season"
example: sort=-race_dateQuite convenient I am sure you can agree!
A More Complex Endpoint
Let’s now move onto documenting our store endpoint which is what is used to create a new race.
/api/races:
post:
summary: ""
operationId: postApiRaces
description: ""
responses: {}
tags:
- Endpoints
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: ""
example: "Monaco Grand Prix"
race_date:
type: string
description: "Must be a valid date."
example: "2024-05-26"
season:
type: string
description: ""
example: "2024"
circuit_id:
type: string
description: ""
example: abc123
required:
- name
- race_date
- season
- circuit_id
security: []For the most part, this has been documented quite well by leaning on the Laravel framework and understanding what the validation rules on the request means. Let’s enhance this by adding some information.
#[
Group(name: 'Races', description: 'A series of endpoints that allow programmatic access to managing F1 races.', authenticated: true),
Authenticated,
Endpoint(title: 'Create a new Race', description: 'Create a new F1 race for a specified circuit and season.'),
]This is similar to what we did on the IndexController but this time we are jumping straight into grouping the attributes all together at the top of the class. We do not need to add these above the invoke method, as this class only performs the one action anyway. I would consider moving these if I were to leverage additional Attributes for different purposes on the method, however for now I am not. Let’s now regenerate the OpenAPI Specification to see what the difference is, but this time I am going to omit the request validation information.
post:
summary: "Create a new Race"
operationId: createANewRace
description: "Create a new F1 race for a specified circuit and season."
responses: {}
tags:
- "Races"
requestBody:
required: true
content:As you can see, the information is starting to build up based on the information we pass through to the PHP Attributes. Let’s start expanding on the request body and response information and build a better specification.
#[
Authenticated,
Group(name: 'Races', description: 'A series of endpoints that allow programmatic access to managing F1 races.', authenticated: true),
Endpoint(title: 'Create a new Race', description: 'Create a new F1 race for a specified circuit and season.'),
BodyParam(name: 'name', type: 'string', description: 'The name of the race.', required: true, example: 'Monaco Grand Prix'),
BodyParam(name: 'race_date', type: 'string', description: 'The date when the race will take place.', required: true, example: '2024-05-26'),
BodyParam(name: 'season', type: 'string', description: 'The season year for this race.', required: true, example: '2024'),
BodyParam(name: 'circuit_id', type: 'string', description: 'The Unique Identifier for the circuit where the race will be held.', required: true, example: '1234-1234-1234-1234'),
BodyParam(name: 'winner', type: 'string', description: 'The driver who won the race (optional, can be added after the race).', required: false, example: 'Max Verstappen'),
ResponseFromApiResource(RaceResource::class, Race::class, collection: false)
]Now we have the body parameters for this request, as well as how the API is expected to respond. We are currently only documenting the happy path - as we have yet to decide how we want to handle errors. This will create the following in your OpenAPI Specification:
post:
summary: "Create a new Race"
operationId: createANewRace
description: "Create a new F1 race for a specified circuit and season."
responses:
200:
description: ""
content:
application/json:
schema:
type: object
example:
data:
id: 9bce14db-cdd1-4a8a-86cb-e05f9f918d20
type: races
attributes:
name: "Monaco Grand Prix"
race_date: "2024-05-26"
season: "2024"
circuit: "Monte Carlo Circuit"
winner: null
created:
human: "0 seconds ago"
timestamp: 1713094155
string: "2024-04-14 11:29:15"
local: "2024-04-14T11:29:15"
properties:
data:
type: object
properties:
id:
type: string
example: 9bce14db-cdd1-4a8a-86cb-e05f9f918d20
type:
type: string
example: races
attributes:
type: object
properties:
name:
type: string
example: "Monaco Grand Prix"
race_date:
type: string
example: "2024-05-26"
season:
type: string
example: "2024"
circuit:
type: string
example: "Monte Carlo Circuit"
winner:
type: string
example: null
created:
type: object
properties:
human:
type: string
example: "0 seconds ago"
timestamp:
type: integer
example: 1713094155
string:
type: string
example: "2024-04-14 11:29:15"
local:
type: string
example: "2024-04-14T11:29:15"
tags:
- "Races"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: "The name of the race."
example: "Monaco Grand Prix"
race_date:
type: string
description: "The date when the race will take place."
example: "2024-05-26"
season:
type: string
description: "The season year for this race."
example: "2024"
circuit_id:
type: string
description: "The Unique Identifier for the circuit where the race will be held."
example: 1234-1234-1234-1234
winner:
type: string
description: "The driver who won the race (optional, can be added after the race)."
example: "Max Verstappen"
required:
- name
- race_date
- season
- circuit_idAs you can see, a lot more information is provided which will help anyone who wants to interact with this API.
Summary
If we follow this approach throughout our API, we can generate a well documented OpenAPI Specification for our Laravel based API - utilizing modern PHP to add information to our code base. This not only aids in the OpenAPI generation, but it also adds a level of in-code documentation that will help onboard any new developer who needs to know what the purpose of an endpoint may be.
Last updated on