Skip to content

Server scaffold

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:

Terminal window
php artisan vendor:publish --tag=openapi-laravel-config
config/openapi-laravel.php
'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:

Terminal window
php artisan openapi:generate

Skip the scaffold for one run with --no-controllers and/or --no-routes, or force it on when the config disables it with --controllers / --routes:

Terminal window
php artisan openapi:generate --no-controllers --no-routes # models only

Scaffold-related flags for the artisan command:

FlagDescription
--no-controllersSkip the abstract controllers
--no-routesSkip the routes file
--controllersGenerate abstract controllers even when disabled in config
--routesGenerate the routes file even when disabled in config
--spec=<path>OpenAPI document to read (overrides config)
--output=<path>Directory for Data classes (overrides config)
--pruneDelete 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:

Terminal window
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
FlagDescription
--no-controllersSkip the abstract controllers
--no-routesSkip the routes file
--controllersGenerate abstract controllers even when disabled in config
--routesGenerate the routes file even when disabled in config
--controller-namespacePHP namespace for the generated abstract controllers
--controller-outputDirectory for the abstract controllers (default: <output>/Controllers)
--routes-outputPath the routes file is written to (default: <output>/routes.php)

Concrete controller stubs: openapi:scaffold

Section titled “Concrete controller stubs: openapi:scaffold”

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:

Terminal window
php artisan openapi:generate # models, abstract controllers, routes
php artisan openapi:scaffold # one-time concrete stubs

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

  • One-time. A stub whose file already exists is skipped and reported, never overwritten. The moment a stub is written it is your code; re-running openapi:scaffold after a spec change only creates stubs for controllers that are new.
  • Always in lockstep with generate. The stub list comes from the same planner that generate and check use, with the same flags and config (including --only-tags / --only-schemas / --exclude-path-prefix), so a stub is created exactly for the controllers the routes file references.
  • Invisible to the drift gate. openapi:check never inspects the concrete controllers, so scaffolded (and then edited) stubs never flag drift.

The standalone binary has the same subcommand:

Terminal window
vendor/bin/openapi-laravel scaffold --spec=openapi.yaml --output=app/Data

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

config/openapi-laravel.php
'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 follow

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

Include the generated file from your application’s route service provider or from routes/api.php:

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 methodPath shapeMethod name
GETcollection (/pets)index
POSTcollection (/pets)store
GETitem (/pets/{petId})show
PUT/PATCHitem (/pets/{petId})update
DELETEitem (/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:

  • Non-CRUD operations (POST on an item path, PUT/PATCH/DELETE on a collection, HEAD/OPTIONS/TRACE) always keep the operationId-derived name.
  • Ambiguity within a controller: when two operations in the same controller (tag) would claim the same conventional name, say two collection GETs (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.
  • Residual collisions (an operationId literally named 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:

  • The Data layer. Generated Data classes, including the per-operation query classes (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.
  • Everything the fallback rules catch. Non-CRUD and ambiguous operations keep their operationId-derived names, so nothing is ever forced into a conventional name that does not fit. The drift gate (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

Parameters

Query, path, and header parameter Data classes. Parameters