Build a versioned Laravel API with auto-generated OpenAPI docs in 10 minutes

Published: (June 11, 2026 at 02:12 AM EDT)
6 min read
Source: Dev.to

Source: Dev.to

TL;DR — We’ll install dskripchenko/laravel-api, write one controller, and end up with a versioned API (/api/v1/...) and interactive OpenAPI 3.0 docs at /api/doc — generated from the docblock you’d write anyway. Then we’ll ship a v2 without copy-pasting a single controller.

The problem

Two things rot in every growing Laravel API:

Versioning. v1 ships, then v2 needs to change three endpoints but keep the other twenty. You either copy-paste a V2 folder (and now bugfixes live in two places) or bolt if ($version === 2) branches into your controllers.

Docs. The OpenAPI spec drifts from the code the moment you merge. Annotation libraries (#[OA\Get(...)], giant YAML files) ask you to describe your API twice — once in code, once in attributes.

This package’s bet: your controller already describes itself. The method name, the request fields, the response shape — write them once, as a normal PHPDoc, and let the package derive routes and docs from it. Versioning becomes plain PHP inheritance.

Let’s build it.

What we’ll build

A tiny tasks API:

POST /api/v1/task/list — list tasks

POST /api/v1/task/create — create one

  • interactive docs at GET /api/doc (raw spec per version at /api/doc/{version})

then a v2 that adds an endpoint without touching v1

Total: ~4 small files.

Step 0 — Install

composer require dskripchenko/laravel-api
Enter fullscreen mode


Exit fullscreen mode

Publish the config (optional, but handy to see the knobs):

php artisan vendor:publish --tag=laravel-api-config
Enter fullscreen mode


Exit fullscreen mode
// config/laravel-api.php
return [
    'prefix'            => 'api',                            // → /api/...
    'uri_pattern'       => '{version}/{controller}/{action}',
    'available_methods' => ['get', 'post', 'put', 'patch', 'delete'],
    'openapi_path'      => 'public/openapi',
    'doc_middleware'    => [],                               // lock down /api/doc here
];
Enter fullscreen mode


Exit fullscreen mode

Step 1 — Write a controller

Nothing exotic — it extends the package’s ApiController, which gives you response helpers (success(), error(), validationError(), created(), noContent(), notFound()). The docblock is the documentation:

success([
            ['id' => 1, 'title' => 'Pack for Vietnam', 'status' => 'done'],
            ['id' => 2, 'title' => 'Write this article', 'status' => 'open'],
        ]);
    }

    /**
     * Create a task
     *
     * @input string $title Task title
     * @input string ?$status Initial status [open,done]
     *
     * @output integer $id New task id
     * @output string $title Task title
     *
     * @return JsonResponse
     */
    public function create(Request $request): JsonResponse
    {
        return $this->created([
            'id'    => 3,
            'title' => $request->input('title'),
        ]);
    }
}
Enter fullscreen mode


Exit fullscreen mode

A few docblock conventions worth knowing:

?$page → optional field.

string $status ... [open,done] → the bracketed list becomes an enum in the spec.

@input integer(int64) $id / @input string(email) $email → type with format.

@input file $avatar → file upload; @input @User $user$ref to a component schema.

Every response is wrapped in a consistent envelope:

{ "success": true, "payload": { ... } }
Enter fullscreen mode


Exit fullscreen mode

Errors (thrown ApiException or $this->error()) come back as:

{ "success": false, "payload": { "errorKey": "string", "message": "string" } }
Enter fullscreen mode


Exit fullscreen mode

Step 2 — Wire up the version, the module, the provider

Three small classes. This is the whole routing layer — no routes/api.php entries.

 [
                'task' => [                          // → /api/v1/task/{action}
                    'controller' => TaskController::class,
                    'actions'    => ['list', 'create'],
                ],
            ],
        ];
    }
}
Enter fullscreen mode


Exit fullscreen mode
 \App\Api\V1\Api::class,
        ];
    }
}
Enter fullscreen mode


Exit fullscreen mode
 ['action' => 'list', 'method' => ['get']]`.

  
  
  Step 4 — The payoff: free OpenAPI docs

Open **`GET /api/doc`** in a browser. You don't get a raw JSON file — you get a ready-to-use **interactive API reference** (rendered with [Scalar](https://github.com/scalar/scalar)), with a version switcher across `v1`, `v2`, … already wired up. The package walked your controllers, read those docblocks, and produced a complete OpenAPI 3.0 document — parameters, enums, response schemas, the lot. These docs **can't drift**, because they *are* the code.

Need the raw spec for CI, a client generator, or your own Redoc/Stoplight setup? Each version is served as JSON at **`GET /api/doc/{version}`** (e.g. `/api/doc/v1`) — no `storage:link`, no build step.

Need TypeScript clients? There's a generator:

php artisan api:generate-types


    Enter fullscreen mode
    

    Exit fullscreen mode
    

…and an exporter for Postman / HTTP Client / Markdown / cURL:

php artisan api:export —format=postman


    Enter fullscreen mode
    

    Exit fullscreen mode
    

  
  
  Step 5 — Versioning without copy-paste

Here's the part that usually hurts. `v2` should add an `archive` endpoint — but leave `v1` untouched. You **extend** the previous version, at both the controller and the `Api` level.

The v2 controller inherits every v1 action and adds the new one:

success([‘archived’ => true]); } }


    Enter fullscreen mode
    

    Exit fullscreen mode
    

And the v2 `Api` inherits every v1 action, swapping in the new controller:

[ ‘task’ => [ ‘controller’ => TaskController::class, // …override just this one ‘actions’ => [ ‘list’, ‘create’, ‘archive’, // add a brand-new action ‘legacyExport’ => false, // → false disables an action (e.g. one inherited from v1) ], ], ], ]; } }


    Enter fullscreen mode
    

    Exit fullscreen mode
    

Register it:

// app/Api/ApiModule.php public function getApiVersionList(): array { return [ ‘v1’ => \App\Api\V1\Api::class, ‘v2’ => \App\Api\V2\Api::class, ]; }


    Enter fullscreen mode
    

    Exit fullscreen mode
    

Now `/api/v2/task/...` is live, `v2`'s docs appear automatically, and **v1 never changed**. Bugfix in a shared action? Fix it once in the base class. Need a clean break? Override the controller. Need to kill an endpoint in the new version? Set the action key to `false` (as with `'legacyExport' => false` above).

Middleware cascades the same way — global → controller → action, with `exclude-middleware` / `exclude-all-middleware` escape hatches at each level.

  
  
  Why this approach

Annotation libs (`#[OA\...]`)
This package

Describe API
in code **and** in attributes
once, in the PHPDoc

Versioning
manual folders / `if` branches
PHP inheritance

Docs drift
possible (separate source)
impossible (derived)

Routes
hand-written
derived from `getMethods()`

It won't replace a full framework for every team — if you love attribute-driven specs, you'll miss them here. But if you've ever grep'd a controller wondering whether the docs still match it, this removes the question entirely.

  
  
  Try it

composer require dskripchenko/laravel-api


    Enter fullscreen mode
    

    Exit fullscreen mode
    

⭐ Repo & full docs: [https://github.com/dskripchenko/laravel-api](https://github.com/dskripchenko/laravel-api)

📦 Packagist: [https://packagist.org/packages/dskripchenko/laravel-api](https://packagist.org/packages/dskripchenko/laravel-api)

I maintain it — issues, ideas, and "this broke on my setup" reports are all welcome. What would you want a versioning-first API package to do that yours doesn't?
0 views
Back to Blog

Related posts

Read more »

The spec is in the wrong place

My day job is at a large tech company. Hundreds of engineering teams, and every one of them is somewhere different on AI adoption. Some are still treating codin...

The Heuristics Say Don't

A culture that only records its disasters ends up with a biased archive. Wars documented, plagues chronicled, collapses catalogued. The quiet decades go unwritt...