Speakeasy Logo
Skip to Content

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  is the go to for generating API documentation form Laravel source code, and it happily exports OpenAPI to be used in a variety of other tools: like Speakeasy.

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/scribe

Once installed, publish the package configuration to access the full variety of config options.

php artisan vendor:publish --tag=scribe-config

There are a lot of config options  available, and we’ll look at some good ones later. For now let’s see what a basic generation looks like.

php artisan scribe:generate

The 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: integer

A 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  (also known as serializers) to figure out what the response payloads would look like, and describe them as OpenAPI Schema objects .

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 , so they should be more realistic than most hand-written examples - unless the seeds are creating bad or outdated data. Here’s how the examples were generated.

'/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  by looking at the route definitions, but query parameters and others need to be documented manually. It’s best practice to document all types of parameters manually because the automatic generation is only spotting if its optional or not, so it will still need a description.

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: false

API 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.

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 , but similar to the earlier examples the generated output is very bare-bones and needs some extra context.

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_date

Not 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 - season

As 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