API Development

Mastering API Versioning and Documentation in Laravel 12

Master API versioning strategies and automated documentation in Laravel 12. Learn versioning best practices, Scribe integration, OpenAPI/Swagger setup, and maintain developer-friendly API docs.

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

|
03-Nov-2025
9 min read
Mastering API Versioning and Documentation in Laravel 12

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

  1. Semantic versioning – Use v1, v2, v3 for major versions
  2. Deprecation notices – Add headers like X-API-Deprecated: true
  3. Version sunset timeline – Give users 6-12 months notice
  4. Default version – Set a sensible default for unversioned requests
  5. Consistent URL structure – Keep endpoints predictable across versions
  6. Share common logic – Use traits and services to avoid duplication
  7. Version documentation separately – Each version should have its own docs
  8. 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.


Article Tags

Laravel 12 API Versioning API Documentation Laravel Scribe OpenAPI Swagger RESTful API Laravel Best Practices API Design Backend Development API Management Laravel Routes API Resources Developer Tools API Maintenance

About the Author

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

Founder & CEO at CentoSquare | Creator of NeedLaravelSite | Helping Businesses Grow with Cutting-Edge Web, Mobile & Marketing Solutions | Building Innovative Products