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 stored.
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 on it. Beyond just outputting endpoints and models, Scribe was able to look through the API resources
Examples were also generated based on the data in the database. This is a great touch at providing some realism immediately, but it made the above example too big to share. The examples generated are based on Laravel’s database seeders
'/api/drivers/{id}':
get:
summary: 'Display the specified resource.'
operationId: displayTheSpecifiedResource
description: ''
parameters: []
responses:
200:
description: ''
content:
application/json:
schema:
type: object
example:
data:
id: 1
name: 'Max Verstappen'
code: VER
created_at: '2025-10-29T17:21:39.000000Z'
updated_at: '2025-10-29T17:21:39.000000Z'
properties:
data:
type: object
properties:
id:
type: integer
example: 1
name:
type: string
example: 'Max Verstappen'
code:
type: string
example: VER
created_at:
type: string
example: '2025-10-29T17:21:39.000000Z'
updated_at:
type: string
example: '2025-10-29T17:21:39.000000Z'However, there are some shortcomings to this auto-generated output.
OpenAPI is used for a lot of different purposes, but in order to use it for API documentation it needs to have useful descriptions that provide context to the raw data. Currently the API missing a lot of the “why” and “how” in this sea of “what”, and needs more human input.
Additionally, the descriptions and summaries are all the same generic content pulled from some template, and having summary: 'Display a listing of the resource.' for each operation (which in turn is giving poor operationId) is not only going to be confusing for users, it 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, but to get the rest of the API covered Scribe will need some extra context spread around the codebase.
Creating summaries and descriptions
Scribe scans application routes to identify which endpoints should be described, 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.
In order to provide that context, Scribe looks at “docblock” comments (/**) on the controller methods.
Take a look at the HealthController because that has no descriptions or summary so far.
# app/Http/Controllers/HealthController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class HealthController extends Controller
{
/**
* Healthcheck
*
* Check that the service is up. If everything is okay, you'll get a 200 OK response.
*
* Otherwise, the request will fail with a 400 error, and a response listing the failed services.
*/
public function show(): JsonResponse
{
return response()->json([
'status' => 'healthy',
'version' => 'unversioned',
'timestamp' => now()->toIso8601String(),
]);
}
}Now when php artisan scribe:generated is run again, the /api/health endpoint will have a proper summary and description. The summary is taken from the first line of the docblock, and the description is taken from the rest of the docblock, which will work just as well in traditional PHP documentation tools as well a the OpenAPI documentation tools after export.
/api/health:
get:
summary: Healthcheck
operationId: healthcheck
description: "Check that the service is up. If everything is okay, you'll get a 200 OK response.\n\nOtherwise, the request will fail with a 400 error, and a response listing the failed services."
parameters: []
# ...Looking at another example, the races collection has some default state and optional values, and the description can be improved to reflect that.
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\RaceCollection;
use App\Models\Race;
use Illuminate\Http\Request;
class RaceController extends Controller
{
/**
* Get races
*
* A collection of race resources, newest first, optionally filtered by circuit or season query parameters.
*/
public function index(Request $request): RaceCollection
{
$query = Race::query();
if ($request->has('circuit')) {
$query->where('circuit_id', $request->circuit);
}
if ($request->has('season')) {
$query->where('season', $request->season);
}
return new RaceCollection($query->get());
}Now the description, summary, and operationId, are all much better, because instead of just saying “it returns stuff” it’s providing a hint about the default state and sorting of the data being returned, and it’s immediately pointing out some options that are likely to be of interest.
/api/races:
get:
summary: 'Get races'
operationId: getRaces
description: 'A collection of race resources, newest first, optionally filtered by circuit or season query parameters.'With descriptions covered, let’s properly document the stuff these descriptions have been eluding to so far. Instead of jamming it into the description, we can use attributes to take advantage of more Scribe and OpenAPI functionality.
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 resource together.
use Knuckles\Scribe\Attributes\{Authenticated, Group, BodyParam, QueryParam};
#[Group(name: 'Races', description: 'A series of endpoints that allow programmatic access to managing F1 races.')]
class RaceController extends Controller
{
// ...Now instead of seeing tags: [Endpoints] in this controllers endpoints, the tag will be Races, and the description will be included in the OpenAPI document as a tag description.
Learn more about OpenAPI tags.
Documenting parameters
The whole point of an API is being able to send and receive data, so describing and documenting API parameters for an endpoint is crucial. OpenAPI supports several types of parameters, with the most common being path, query, and header parameters.
Scribe can automatically describe path parameters
In the race API, the RaceController@index method supports two optional query parameters: season and circuit. Let’s document those using Scribe’s QueryParam attribute.
/**
* Get races
*
* A collection of race resources, newest first, optionally filtered by circuit or season query parameters.
*/
#[QueryParam(name: 'season', type: 'string', description: 'Filter the results by season year', required: false, example: '2024')]
#[QueryParam(name: 'circuit', type: 'string', description: 'Filter the results by circuit name', required: false, example: 'Monaco')]
public function index(Request $request): RaceCollection
{The result of the above will be the following inside your OpenAPI specification:
summary: 'Get races'
operationId: getRaces
description: 'A collection of race resources, newest first, optionally filtered by circuit or season query parameters.'
parameters:
-
in: query
name: season
description: 'Filter the results by season year'
example: '2024'
required: false
schema:
type: string
description: 'Filter the results by season year'
example: '2024'
nullable: false
-
in: query
name: circuit
description: 'Filter the results by circuit name'
example: Monaco
required: false
schema:
type: string
description: 'Filter the results by circuit name'
example: Monaco
nullable: falseAPI consumers looking at the docs will be able to see what query parameters are available, what they do, and some examples of how to use them, which can really speed up adoption.
Scribe will set in: query for any QueryParam parameters, and in: path for UrlParam parameters.
Note on duplication
The example and description are repeated in both the parameter definition and the schema definition, which is not required by OpenAPI itself, but its the most compatible way to ensure all tools can read the information correctly, and seeing as its automatically generated it doesn’t hurt to have the duplication.
Documenting request bodies
APIs also need to accept data, and in RESTful APIs this is typically done through POST, PUT, and PATCH requests that contain a request body. Scribe can automatically generate request body schemas by looking at Laravel’s form request validation rules
Given the RaceController has a bog standard store method for creating new races, let’s see how the automatic generation looks first.
/**
* Create a race
*
* Allows authenticated users to submit a new Race resource to the system.
*/
public function store(Request $request): RaceResource
{
$validated = $request->validate([
'name' => 'required|string',
'circuit_id' => 'required|integer|exists:circuits,id',
'race_date' => 'required|date',
'season' => 'nullable|string',
'driver_ids' => 'sometimes|array',
'driver_ids.*' => 'integer|exists:drivers,id',
]);
$race = Race::create([
'name' => $validated['name'],
'circuit_id' => $validated['circuit_id'],
'race_date' => $validated['race_date'],
'season' => $validated['season'] ?? null,
]);
if (isset($validated['driver_ids'])) {
$race->drivers()->attach($validated['driver_ids']);
}
return new RaceResource($race);
}This will generate the following OpenAPI for the POST /api/races endpoint:
post:
summary: 'Create a race'
operationId: createARace
description: 'Allows authenticated users to submit a new Race resource to the system.'
parameters: []
responses: { }
tags:
- Endpoints
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: ''
example: architecto
nullable: false
circuit_id:
type: integer
description: 'The <code>id</code> of an existing record in the circuits table.'
example: 16
nullable: false
race_date:
type: string
description: 'Must be a valid date.'
example: '2025-11-16T14:53:59'
nullable: false
season:
type: string
description: ''
example: architecto
nullable: true
driver_ids:
type: array
description: 'The <code>id</code> of an existing record in the drivers table.'
example:
- 16
items:
type: integer
required:
- name
- circuit_id
- race_dateNot bad! Some information is being pulled from the validation rules, such as required fields and types, but the descriptions are pretty much useless, and some of the examples don’t make sense.
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.
/**
* Create a race
*
* Allows authenticated users to submit a new Race resource to the system.
*/
#[Authenticated]
#[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 and time the race takes place, RFC 3339 in local timezone.', required: true, example: '2024-05-26T14:53:59')]
#[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: 'season', type: 'string', description: 'The season year for this race.', required: true, example: '2024')]
#[BodyParam(name: 'driver_ids', type: 'array', description: 'An array of Unique Identifiers for drivers participating in the race.', required: false, example: [ "5678-5678-5678-5678", "6789-6789-6789-6789" ])]
public function store(Request $request): RaceResource
Let’s take a look at the resulting OpenAPI for the request body now:
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: 'The name of the race.'
example: 'Monaco Grand Prix'
nullable: false
circuit_id:
type: string
description: 'The Unique Identifier for the circuit where the race will be held.'
example: 1234-1234-1234-1234
nullable: false
race_date:
type: string
description: 'The date and time the race takes place, RFC 3339 in local timezone.'
example: '2024-05-26T14:53:59'
nullable: false
season:
type: string
description: 'The season year for this race.'
example: '2024'
nullable: false
driver_ids:
type: array
description: 'An array of Unique Identifiers for drivers participating in the race.'
example:
- 5678-5678-5678-5678
- 6789-6789-6789-6789
items:
type: string
required:
- name
- circuit_id
- race_date
- seasonAs you can see, a lot more information is provided which will help anyone who wants to interact with this API.
Summary
Generating an OpenAPI specification for your Laravel API is a great way to improve developer experience and streamline API consumption after the API has been built. When API design-first is not an option, “catching up” with Scribe means you can quickly get to the point of having a complete OpenAPI document that can be used in tools like Speakeasy to generate SDKs, tests, and more.
Last updated on