Build a versioned Laravel API with auto-generated OpenAPI docs in 10 minutes
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?