As your application evolves and scales, maintaining clear API versioning and comprehensive documentation becomes critical for success. Laravel 12 provides elegant solutions for organizing versioned APIs while seamlessly integrating with modern documentation tools. This guide covers industry-standard versioning strategies, automated documentation generation, and best practices for maintaining long-term API stability.
Why API Versioning is Essential
API versioning is not just a technical requirement—it's a commitment to your API consumers. Breaking changes without versioning can disrupt client applications, frustrate developers, and damage your product's reputation.
Key Benefits of API Versioning
- Backward compatibility – Existing clients continue functioning during updates
- Gradual migration – Users transition to new versions at their own pace
- Feature experimentation – Test new features without affecting production users
- Clear deprecation path – Communicate changes and sunset timelines
- Multiple client support – Serve different versions to mobile, web, and third-party apps
- Reduced maintenance burden – Isolate changes to specific versions
When to Create a New API Version
Create a new version when you introduce:
- Changes to response data structure
- Removal of endpoints or fields
- Modified authentication mechanisms
- Breaking changes to request parameters
- Significant behavioral changes
- New required fields in requests
Implementing URL-Based Versioning in Laravel 12
URL-based versioning is the most common and visible approach, making it clear which version clients are using.
Basic Route Configuration
Organize your API routes with version prefixes in routes/api.php:
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V2;
// Version 1 Routes
Route::prefix('v1')->name('v1.')->group(function () {
Route::apiResource('users', V1\UserController::class);
Route::apiResource('posts', V1\PostController::class);
Route::get('profile', [V1\ProfileController::class, 'show']);
});
// Version 2 Routes
Route::prefix('v2')->name('v2.')->group(function () {
Route::apiResource('users', V2\UserController::class);
Route::apiResource('posts', V2\PostController::class);
Route::get('profile', [V2\ProfileController::class, 'show']);
});
Advanced Route Organization
For complex applications, create separate route files per version:
// routes/api.php
Route::prefix('v1')->group(base_path('routes/api/v1.php'));
Route::prefix('v2')->group(base_path('routes/api/v2.php'));
Create routes/api/v1.php:
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('users', V1\UserController::class);
Route::apiResource('posts', V1\PostController::class);
Route::post('posts/{post}/publish', [V1\PostController::class, 'publish']);
});
Route::get('public/posts', [V1\PostController::class, 'public']);
Directory Structure for Versioned APIs
Organize your codebase to clearly separate version-specific logic:
app/
├── Http/
│ ├── Controllers/
│ │ └── Api/
│ │ ├── V1/
│ │ │ ├── UserController.php
│ │ │ ├── PostController.php
│ │ │ └── ProfileController.php
│ │ └── V2/
│ │ ├── UserController.php
│ │ ├── PostController.php
│ │ └── ProfileController.php
│ ├── Resources/
│ │ └── Api/
│ │ ├── V1/
│ │ │ ├── UserResource.php
│ │ │ └── PostResource.php
│ │ └── V2/
│ │ ├── UserResource.php
│ │ └── PostResource.php
│ └── Requests/
│ └── Api/
│ ├── V1/
│ │ ├── StoreUserRequest.php
│ │ └── UpdateUserRequest.php
│ └── V2/
│ ├── StoreUserRequest.php
│ └── UpdateUserRequest.php
└── Services/
└── Api/
├── V1/
└── V2/
Version-Specific Controllers and Resources
Version 1 Controller
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\UserResource;
use App\Models\User;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class UserController extends Controller
{
/**
* Display a listing of users.
*/
public function index(): AnonymousResourceCollection
{
$users = User::query()
->with('profile')
->paginate(15);
return UserResource::collection($users);
}
/**
* Display the specified user.
*/
public function show(User $user): UserResource
{
return new UserResource($user->load('profile'));
}
}
Version 2 Controller with Breaking Changes
namespace App\Http\Controllers\Api\V2;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V2\UserResource;
use App\Models\User;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class UserController extends Controller
{
/**
* Display a listing of users with enhanced filtering.
*/
public function index(): AnonymousResourceCollection
{
$users = User::query()
->with(['profile', 'settings', 'preferences'])
->filter(request()->only(['status', 'role', 'search']))
->paginate(request()->input('per_page', 20));
return UserResource::collection($users);
}
/**
* Display the specified user with additional data.
*/
public function show(User $user): UserResource
{
return new UserResource(
$user->load(['profile', 'posts', 'settings'])
);
}
}
Version 1 Resource
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->toISOString(),
];
}
}
Version 2 Resource with Enhanced Structure
namespace App\Http\Resources\Api\V2;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'profile' => [
'name' => $this->name,
'email' => $this->email,
'avatar' => $this->profile?->avatar_url,
],
'metadata' => [
'member_since' => $this->created_at->toISOString(),
'last_active' => $this->last_active_at?->toISOString(),
'posts_count' => $this->posts_count ?? 0,
],
'settings' => new SettingsResource($this->whenLoaded('settings')),
];
}
}
Header-Based Versioning (Alternative Approach)
Instead of URL versioning, you can use custom headers:
// Middleware: ApiVersionMiddleware.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiVersionMiddleware
{
public function handle(Request $request, Closure $next)
{
$version = $request->header('API-Version', 'v1');
// Store version for later use
$request->merge(['api_version' => $version]);
return $next($request);
}
}
Route configuration:
Route::middleware(['api.version'])->group(function () {
Route::get('users', function (Request $request) {
$version = $request->input('api_version');
$controller = "App\\Http\\Controllers\\Api\\{$version}\\UserController";
return app($controller)->index();
});
});
Generating API Documentation with Scribe
Scribe is the most Laravel-friendly documentation tool, generating beautiful, interactive API documentation automatically from your code.
Installation and Setup
composer require knuckleswtf/scribe --dev
php artisan scribe:install
This creates config/scribe.php for configuration.
Configuring Scribe
Update config/scribe.php:
return [
'theme' => 'default',
'title' => 'My Laravel API Documentation',
'description' => 'Comprehensive API documentation for Laravel 12 application',
'base_url' => env('APP_URL', 'http://localhost'),
'routes' => [
[
'match' => [
'prefixes' => ['api/v1/*'],
'domains' => ['*'],
],
'include' => [],
'exclude' => [],
],
],
'type' => 'laravel',
'static' => [
'output_path' => 'public/docs',
],
'laravel' => [
'add_routes' => true,
'docs_url' => '/docs',
],
'postman' => [
'enabled' => true,
],
'openapi' => [
'enabled' => true,
],
'try_it_out' => [
'enabled' => true,
],
'auth' => [
'enabled' => true,
'default' => false,
],
];
Annotating Your Controllers
Add Scribe annotations to your controllers:
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\UserResource;
use App\Models\User;
/**
* @group User Management
*
* APIs for managing users
*/
class UserController extends Controller
{
/**
* List all users
*
* Retrieve a paginated list of all users in the system.
*
* @queryParam page integer Page number for pagination. Example: 1
* @queryParam per_page integer Number of items per page. Example: 15
* @queryParam search string Search term for filtering users. Example: john
*
* @response 200 scenario="success" {
* "data": [
* {
* "id": 1,
* "name": "John Doe",
* "email": "john@example.com",
* "created_at": "2025-01-15T10:30:00.000000Z"
* }
* ],
* "links": {...},
* "meta": {...}
* }
*/
public function index()
{
$users = User::query()
->when(request('search'), function ($query, $search) {
$query->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
})
->paginate(request('per_page', 15));
return UserResource::collection($users);
}
/**
* Get user details
*
* Retrieve detailed information about a specific user.
*
* @urlParam user integer required The ID of the user. Example: 1
*
* @response 200 scenario="success" {
* "data": {
* "id": 1,
* "name": "John Doe",
* "email": "john@example.com",
* "created_at": "2025-01-15T10:30:00.000000Z"
* }
* }
*
* @response 404 scenario="not found" {
* "message": "User not found"
* }
*/
public function show(User $user)
{
return new UserResource($user);
}
/**
* Create a new user
*
* @bodyParam name string required The user's full name. Example: John Doe
* @bodyParam email string required The user's email address. Example: john@example.com
* @bodyParam password string required The user's password (min 8 characters). Example: secret123
* @bodyParam password_confirmation string required Password confirmation. Example: secret123
*
* @response 201 scenario="success" {
* "data": {
* "id": 1,
* "name": "John Doe",
* "email": "john@example.com",
* "created_at": "2025-01-15T10:30:00.000000Z"
* }
* }
*
* @response 422 scenario="validation error" {
* "message": "The given data was invalid.",
* "errors": {
* "email": ["The email has already been taken."]
* }
* }
*/
public function store(StoreUserRequest $request)
{
$user = User::create($request->validated());
return new UserResource($user);
}
}
Generating Documentation
php artisan scribe:generate
Access your documentation at http://localhost/docs or public/docs/index.html.
Implementing OpenAPI/Swagger Documentation
For enterprise applications requiring OpenAPI specification, use L5-Swagger.
Installation
composer require darkaonline/l5-swagger
php artisan vendor:publish --provider="L5Swagger\L5SwaggerServiceProvider"
Configuration
Update config/l5-swagger.php:
return [
'default' => 'v1',
'documentations' => [
'v1' => [
'api' => [
'title' => 'Laravel API V1',
],
'routes' => [
'api' => 'api/v1/documentation',
],
'paths' => [
'annotations' => [
base_path('app/Http/Controllers/Api/V1'),
],
],
],
'v2' => [
'api' => [
'title' => 'Laravel API V2',
],
'routes' => [
'api' => 'api/v2/documentation',
],
'paths' => [
'annotations' => [
base_path('app/Http/Controllers/Api/V2'),
],
],
],
],
];
Swagger Annotations
Add OpenAPI annotations to your controllers:
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
/**
* @OA\Info(
* title="Laravel API V1",
* version="1.0.0",
* description="RESTful API for Laravel application",
* @OA\Contact(
* email="support@example.com"
* )
* )
*
* @OA\Server(
* url="http://localhost:8000/api/v1",
* description="Development Server"
* )
*
* @OA\SecurityScheme(
* securityScheme="sanctum",
* type="http",
* scheme="bearer",
* bearerFormat="JWT"
* )
*/
class Controller extends BaseController
{
//
}
/**
* @OA\Get(
* path="/users",
* tags={"Users"},
* summary="Get list of users",
* description="Returns paginated list of users",
* @OA\Parameter(
* name="page",
* in="query",
* description="Page number",
* required=false,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Successful operation",
* @OA\JsonContent(
* @OA\Property(property="data", type="array",
* @OA\Items(
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="John Doe"),
* @OA\Property(property="email", type="string", example="john@example.com")
* )
* )
* )
* ),
* security={{"sanctum": {}}}
* )
*/
class UserController extends Controller
{
public function index()
{
// Implementation
}
}
Generate Swagger Documentation
php artisan l5-swagger:generate
Access at http://localhost/api/v1/documentation and http://localhost/api/v2/documentation.
Best Practices for API Versioning
- Semantic versioning – Use v1, v2, v3 for major versions
- Deprecation notices – Add headers like
X-API-Deprecated: true - Version sunset timeline – Give users 6-12 months notice
- Default version – Set a sensible default for unversioned requests
- Consistent URL structure – Keep endpoints predictable across versions
- Share common logic – Use traits and services to avoid duplication
- Version documentation separately – Each version should have its own docs
- Changelog maintenance – Document all changes between versions
Maintaining Documentation
Automated CI/CD Integration
# .github/workflows/docs.yml
name: Generate API Documentation
on:
push:
branches: [main]
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
- name: Install dependencies
run: composer install
- name: Generate documentation
run: php artisan scribe:generate
- name: Deploy docs
run: # Deploy to hosting
Conclusion
Mastering API versioning and documentation in Laravel 12 is essential for building maintainable, scalable APIs. By implementing URL-based versioning with organized directory structures, leveraging Scribe for automated documentation generation, and using OpenAPI/Swagger for enterprise-grade specs, you create APIs that are easy to understand, maintain, and evolve.
Remember to version early, document thoroughly, communicate deprecations clearly, and provide smooth migration paths for your API consumers. These practices ensure long-term success and developer satisfaction.