Configuration
The full list of config keys including controllers and routes. Configuration
The server scaffold feature generates two things from your OpenAPI spec: one abstract controller per tag, with one abstract method per operation, and a routes file that maps every operation to the concrete controller the user writes. The routing table derives from the spec, so path-level drift between spec and code is structurally impossible.
An unimplemented operation in your concrete controller is a PHP fatal at class-definition time, not a silent gap discovered in production. The abstract layer enforces completeness; the routes file enforces that every operation has a registered handler.
openapi.yaml └── openapi-laravel ├── app/Http/Controllers/Api/AbstractPetController.php (generated, overwritten each run) ├── app/Http/Controllers/Api/AbstractStoreController.php └── routes/api.generated.php (generated, overwritten each run)
You write (or scaffold once with openapi:scaffold): ├── app/Http/Controllers/Api/PetController.php (extends AbstractPetController) └── app/Http/Controllers/Api/StoreController.php (extends AbstractStoreController)The abstract files are overwritten on every generator run. Your concrete controllers are never touched. openapi:scaffold writes the initial concrete stubs for you, one time.
The scaffold is generated by default: a plain php artisan openapi:generate emits the Data classes, the abstract controllers, and the routes file in one run. The config file and the command-line flags control it, with strict precedence: flags beat config, config beats the defaults.
Publish the config if you have not already, then adjust the paths, or disable the sections you do not want:
php artisan vendor:publish --tag=openapi-laravel-config'controllers' => [ 'enabled' => true, // false disables controller generation 'path' => app_path('Http/Controllers/Api'), 'namespace' => 'App\\Http\\Controllers\\Api',],
'routes' => [ 'enabled' => true, // false disables the routes file 'path' => base_path('routes/api.generated.php'), 'middleware' => [], // e.g. ['api', 'throttle:60,1'] wraps the routes in a group 'prefix' => '', // e.g. 'api/v1' prefixes every generated route],Then run the generator with no extra flags:
php artisan openapi:generateSkip the scaffold for one run with --no-controllers and/or --no-routes, or force it on when the config disables it with --controllers / --routes:
php artisan openapi:generate --no-controllers --no-routes # models onlyScaffold-related flags for the artisan command:
| Flag | Description |
|---|---|
--no-controllers | Skip the abstract controllers |
--no-routes | Skip the routes file |
--controllers | Generate abstract controllers even when disabled in config |
--routes | Generate the routes file even when disabled in config |
--spec=<path> | OpenAPI document to read (overrides config) |
--output=<path> | Directory for Data classes (overrides config) |
--prune | Delete stale *.php files in the Data output directory |
The standalone binary scaffolds by default too. Without explicit flags (or an openapi-laravel.json config file), the controllers go to <output>/Controllers and the routes file to <output>/routes.php. Override the locations explicitly:
vendor/bin/openapi-laravel \ --spec=openapi.yaml \ --output=app/Data \ --controller-namespace="App\\Http\\Controllers\\Api" \ --controller-output=app/Http/Controllers/Api \ --routes-output=routes/api.generated.php| Flag | Description |
|---|---|
--no-controllers | Skip the abstract controllers |
--no-routes | Skip the routes file |
--controllers | Generate abstract controllers even when disabled in config |
--routes | Generate the routes file even when disabled in config |
--controller-namespace | PHP namespace for the generated abstract controllers |
--controller-output | Directory for the abstract controllers (default: <output>/Controllers) |
--routes-output | Path the routes file is written to (default: <output>/routes.php) |
Right after a first openapi:generate, the routes file references concrete controller classes
that do not exist yet, so the very first request would fatal. Instead of hand-writing every
controller before the app boots, scaffold them:
php artisan openapi:generate # models, abstract controllers, routesphp artisan openapi:scaffold # one-time concrete stubsopenapi:scaffold writes one concrete stub per generated abstract controller, into the same
directory and namespace, exactly where the routes file expects them. Every abstract method is
implemented as an explicit placeholder:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Data\Pet\PetData;use LogicException;
/** * Scaffolded once by openapi-laravel (issue #78). This file is yours: it is * never overwritten, never regenerated, and never drift-checked. Replace * each placeholder body with your implementation. */final class PetController extends AbstractPetController{ public function show(int $petId): PetData { throw new LogicException('Not implemented: show.'); }
// ... one placeholder per operation}The app boots immediately: an unimplemented operation answers with a clear LogicException
instead of a missing-class fatal, and you replace the placeholders at your own pace.
Three properties define the command:
openapi:scaffold after a spec change only
creates stubs for controllers that are new.--only-tags / --only-schemas /
--exclude-path-prefix), so a stub is created exactly for the controllers the routes file
references.openapi:check never inspects the concrete controllers, so
scaffolded (and then edited) stubs never flag drift.The standalone binary has the same subcommand:
vendor/bin/openapi-laravel scaffold --spec=openapi.yaml --output=app/DataBecause the stubs extend the abstracts, scaffolding with controllers disabled
(controllers.enabled: false or --no-controllers) is a hard error rather than a pile of
subclasses of classes that are never generated; pass --controllers to force the plan on over a
disabling config.
The Petstore spec has three tags (pet, store, user), so the generator writes three abstract controllers. Here is what the pet tag produces.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Data\Pet\ApiResponseData;use App\Data\Pet\FindPetsByStatusQueryData;use App\Data\Pet\FindPetsByTagsQueryData;use App\Data\Pet\PetData;use App\Data\Pet\UpdatePetWithFormQueryData;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Spatie\LaravelData\DataCollection;
abstract class AbstractPetController{ /** * POST /pet * * Add a new pet to the store. */ abstract public function addPet(PetData $pet): PetData;
/** * PUT /pet * * Update an existing pet. */ abstract public function updatePet(PetData $pet): PetData;
/** * GET /pet/findByStatus * * Finds Pets by status. * * @return DataCollection<int, PetData> */ abstract public function findPetsByStatus(FindPetsByStatusQueryData $query): DataCollection;
/** * GET /pet/findByTags * * Finds Pets by tags. * * @return DataCollection<int, PetData> */ abstract public function findPetsByTags(FindPetsByTagsQueryData $query): DataCollection;
/** * GET /pet/{petId} * * Find pet by ID. * * Path parameters: validate them with * \App\Data\Pet\GetPetByIdPathData::fromRoute($request). */ abstract public function show(int $petId): PetData;
/** * POST /pet/{petId} * * Updates a pet in the store with form data. * * Path parameters: validate them with * \App\Data\Pet\UpdatePetWithFormPathData::fromRoute($request). */ abstract public function updatePetWithForm(UpdatePetWithFormQueryData $query, int $petId): PetData;
/** * DELETE /pet/{petId} * * Deletes a pet. * * Path parameters: validate them with * \App\Data\Pet\DeletePetPathData::fromRoute($request). * * Header parameters: validate them with * \App\Data\Pet\DeletePetHeaderData::fromHeaders($request). */ abstract public function destroy(int $petId): JsonResponse;
/** * POST /pet/{petId}/uploadImage * * Uploads an image. * * Query parameters: validate and hydrate them with * \App\Data\Pet\UploadFileQueryData::fromQuery($request). * * Path parameters: validate them with * \App\Data\Pet\UploadFilePathData::fromRoute($request). */ abstract public function uploadFile(Request $request, int $petId): ApiResponseData;}By default the generated abstracts extend nothing: framework-light by design, so the scaffold
never fights your project’s own controller hierarchy. If you want the generated controllers rooted
in a base class (Laravel’s App\Http\Controllers\Controller, or your own with shared helpers,
authorization traits, and so on), set controllers.base_class:
'controllers' => [ // ... 'base_class' => 'App\\Http\\Controllers\\Controller',],Every generated abstract then imports and extends it:
use App\Http\Controllers\Controller;
abstract class AbstractPetController extends Controller{ // ...}The value is validated as a legal fully-qualified class name before any file is written (a
malformed value is a configuration error, exit code 2), and a base class whose short name would
collide with the abstract class or one of its imports fails loudly instead of emitting broken PHP.
On the standalone binary the same key lives in openapi-laravel.json
(controllers.base_class, a non-empty string) and the --controller-base-class=<fqcn> flag
overrides it per run; a bare --controller-base-class= clears a configured base. openapi:check
shares the planner with generate, so changing the base class flags drift until you regenerate.
You extend the abstract class and implement every method (openapi:scaffold writes the initial version of this file for you). PHP will refuse to instantiate the class if any abstract method is missing, so there is no way to silently leave an operation unhandled.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Data\FindPetsByStatusQueryData;use App\Data\PetData;use App\Services\PetService;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Spatie\LaravelData\DataCollection;
final class PetController extends AbstractPetController{ public function __construct(private readonly PetService $petService) {}
public function findPetsByStatus(FindPetsByStatusQueryData $query): DataCollection { // $query is already validated against the spec-derived rules() and // hydrated from the query string when Laravel injects it. return PetData::collect($this->petService->findByStatus($query->status), DataCollection::class); }
public function addPet(PetData $pet): PetData { return $this->petService->create($pet); }
public function show(int $petId): PetData { return $this->petService->findOrFail($petId); }
public function destroy(int $petId): JsonResponse { $this->petService->delete($petId); return response()->json(null, 204); }
// ... implement remaining abstract methods}<?php
declare(strict_types=1);
/* * GENERATED by openapi-laravel. Do not edit. * Re-run the generator to refresh routes from the spec. */
use App\Http\Controllers\Api\PetController;use App\Http\Controllers\Api\StoreController;use App\Http\Controllers\Api\UserController;use Illuminate\Support\Facades\Route;
Route::post('/pet', [PetController::class, 'addPet'])->name('addPet');Route::put('/pet', [PetController::class, 'updatePet'])->name('updatePet');Route::get('/pet/findByStatus', [PetController::class, 'findPetsByStatus'])->name('findPetsByStatus');Route::get('/pet/findByTags', [PetController::class, 'findPetsByTags'])->name('findPetsByTags');Route::get('/pet/{petId}', [PetController::class, 'show'])->name('show');Route::post('/pet/{petId}', [PetController::class, 'updatePetWithForm'])->name('updatePetWithForm');Route::delete('/pet/{petId}', [PetController::class, 'destroy'])->name('destroy');Route::post('/pet/{petId}/uploadImage', [PetController::class, 'uploadFile'])->name('uploadFile');Route::get('/store/inventory', [StoreController::class, 'index'])->name('index');Route::post('/store/order', [StoreController::class, 'store'])->name('store');Route::get('/store/order/{orderId}', [StoreController::class, 'show'])->name('show_2');Route::delete('/store/order/{orderId}', [StoreController::class, 'destroy'])->name('destroy_2');// ... user routes followEvery generated route carries a ->name() that follows the controller method name: the
conventional index/show/store/update/destroy for a clean RESTful operation, otherwise
the operationId-derived identifier (an operation without an operationId gets a deterministic
name synthesized from the HTTP method and path); see
Laravel-convention method names. Names are unique across the
whole route table: if two operations in different controllers resolve to the same identifier, the
later one (in path-then-method order) gets a numeric suffix (a second controller’s index route
becomes index_2). That means the route() helper, URL generation, and route-based authorization
can target any generated operation:
route('show', ['petId' => 7]); // => /pet/7Include the generated file from your application’s route service provider or from routes/api.php:
require base_path('routes/api.generated.php');Or from bootstrap/app.php in Laravel 11+:
->withRouting( api: __DIR__.'/../routes/api.php', apiPrefix: 'api',)For middleware groups, route prefixes, and per-operation security middleware from the spec, see Security & middleware.
For response status codes (201, 204, RespondsWithStatus), method signatures, inline request and
response bodies, multipart and form-urlencoded bodies, see
Request & response bodies.
For query, path, and header parameter Data classes, see Parameters.
An operationId is unambiguous but often reads like apiV1AppointmentStoreAppointmentPOST in
real-world specs, and Laravel developers expect store, index, show, update, destroy. So
every clean RESTful operation gets the conventional name, in the abstract controller AND in the
route name:
| HTTP method | Path shape | Method name |
|---|---|---|
GET | collection (/pets) | index |
POST | collection (/pets) | store |
GET | item (/pets/{petId}) | show |
PUT/PATCH | item (/pets/{petId}) | update |
DELETE | item (/pets/{petId}) | destroy |
The path rule. A path is an item path when its last non-empty segment is a path parameter
(/pets/{petId}, /users/{id}/pets/{petId}); every other path is a collection path, including
the root / and paths whose parameter sits in the middle (/users/{id}/pets). The
classification is literal and deterministic, no heuristics.
The fallback rule. Anything that does not fit falls back to the operationId-derived name, deterministically:
POST on an item path, PUT/PATCH/DELETE on a collection,
HEAD/OPTIONS/TRACE) always keep the operationId-derived name.GET /pet/findByStatus and
GET /pet/findByTags both mapping to index), BOTH keep their operationId-derived names. The
rule is order-independent: nobody wins by sort position.index next to a conventional index)
go through the same per-controller unique-name machinery as before and get a numeric suffix.For the Petstore, that produces:
Route::get('/pet/findByStatus', [PetController::class, 'findPetsByStatus'])->name('findPetsByStatus');Route::get('/pet/findByTags', [PetController::class, 'findPetsByTags'])->name('findPetsByTags');Route::get('/pet/{petId}', [PetController::class, 'show'])->name('show');Route::post('/pet/{petId}', [PetController::class, 'updatePetWithForm'])->name('updatePetWithForm');Route::delete('/pet/{petId}', [PetController::class, 'destroy'])->name('destroy');Route::post('/store/order', [StoreController::class, 'store'])->name('store');Route::get('/store/order/{orderId}', [StoreController::class, 'show'])->name('show_2');The two findPetsBy* collection GETs are ambiguous, so both fall back; POST /pet/{petId} is
non-CRUD, so it falls back too. Route names follow the chosen method names and stay globally
unique, so a second controller’s show becomes show_2.
Two things deliberately stay operationId-derived:
FindPetsByStatusQueryData) and
inline request-body classes (CreatePetRequestData), keep their
operationId-derived names: they live in the shared Data namespace, where an IndexQueryData or
StoreRequestData would collide across controllers.openapi:check) verifies the same naming through the shared planner.The --prune flag and output.prune: true config key apply to the Data classes directory only. Abstract controller files are always overwritten (they are fully generated output). Your concrete controllers in the same directory are never deleted, with or without pruning enabled.
Configuration
The full list of config keys including controllers and routes. Configuration
Generated models & enums
How Data classes, enums, and the read/write variant split work. Generated output
Request & response bodies
Method signatures, inline bodies, multipart, and response status. Request & response bodies
Parameters
Query, path, and header parameter Data classes. Parameters