# How To Generate an OpenAPI/Swagger Spec for Laravel APIs

# How To Generate an OpenAPI for Laravel

Investing in an API properly means creating an OpenAPI document that accurately describes the API, for documentation, testing, security scans, governance, and SDK generation. With the rise in popularity of API design-first 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](https://scribe.knuckles.wtf/laravel) is the go-to choice for generating API documentation from 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 (or upgrade to) Scribe v5 and explore the options available.

```bash
composer require --dev knuckleswtf/scribe:^5.0
composer update knuckleswtf/scribe
```

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

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

There are a lot of [config options](https://scribe.knuckles.wtf/laravel/reference/config) available, and we'll look at some good ones later. For now just open the config file and set the `openapi.enabled` option to `true` to enable OpenAPI generation, and make sure the version is set to `3.1.0` for the best compatibility with Speakeasy.

```php
    'openapi' => [
        'enabled' => true,
        'version' => '3.1.0',
```

Now it's time to generate the documentation and OpenAPI document for the first time. Run the following command:

```bash
php artisan scribe:generate
```

The command above will generate both HTML documentation and an OpenAPI specification file. By default (for `type: laravel`), the OpenAPI document is saved in `storage/app/scribe/openapi.yaml`, but the command will let you know exactly where it's stored.

```yaml
openapi: 3.1.0
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](https://laravel.com/docs/12.x/eloquent-resources) (also known as serializers) to figure out what the response payloads would look like, and describe them as [OpenAPI Schema objects](https://learn.openapis.org/specification/content.html#the-schema-object).

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](https://laravel.com/docs/12.x/seeding), 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.

```yaml
  '/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:

```php
  // 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.

```php focus=10:16
# 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:generate` 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 as OpenAPI documentation tools after export.

```yaml
/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.

```php focus=10:14
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.

```yaml
  /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. 

```php
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](/openapi/tags).**

## Documenting parameters

The whole point of an API is being able to send and receive data, so describing and documenting [API parameters](/api-design/parameters) for an endpoint is crucial. OpenAPI supports [several types of parameters](/openapi/requests/parameters), with the most common being `path`, `query`, and `header` parameters. 

Scribe can [automatically describe path parameters](https://scribe.knuckles.wtf/laravel/documenting/url-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.

```php focus=6:7
/**
 * 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:

```yaml
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. 

<Callout type="info" title="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.
</Callout>

## 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](https://laravel.com/docs/12.x/validation#form-request-validation), 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.

```php
  /**
   * 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:

```yaml
    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.

```php
  /**
   * 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:

```yaml
    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.

## Outputting other OpenAPI versions

This article and sample code outputs OpenAPI v3.1, but other versions can be configured in `config/scribe.php`. For example if an older tool needs to use OpenAPI v3.0, the following can be set:

```php
    'openapi' => [
        'version' => '3.0.0',
```

## Summary

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 without having to duplicate a lot of information. Let the code do the talking, and enable tools like Speakeasy to generate SDKs, tests, and more.
