Backend development

Designing REST API with Laravel: Principles, Versioning, and Documentation in 2026

Ruslan Ismailov Published 11 min read
D

Introduction: Why API Design Matters More Than Implementation

Most developers write code first and think about architecture later. With REST APIs, this approach is especially destructive: poor decisions made during the design phase turn into technical debt that's nearly impossible to repay without breaking changes.

In 2026, Laravel remains one of the most popular PHP frameworks for building API backends. Its ecosystem is mature and its toolset is rich — but that doesn't eliminate the need to think before you write. This article is about how to design a REST API with Laravel correctly from the very start.

RESTful Design Principles

Resources and Route Naming

REST is built around resources, not actions. A URL should represent a noun, and the HTTP method should act as the verb.

  • Correct: GET /api/v1/articles
  • Incorrect: GET /api/v1/getArticles

Use plural nouns for collections (/users), nested routes for relationships (/users/42/posts), and avoid deep nesting beyond two levels — it complicates client-side code.

HTTP Methods and Their Semantics

  • GET — retrieve a resource or collection (safe, idempotent)
  • POST — create a new resource
  • PUT — full resource update (idempotent)
  • PATCH — partial update
  • DELETE — remove a resource (idempotent)

HTTP Status Codes

Using status codes correctly is a hallmark of a mature API. Don't return 200 OK for every request with an error message hidden in the body.

  • 200 — successful GET/PUT/PATCH
  • 201 — successful POST (resource created)
  • 204 — successful DELETE (empty body)
  • 400 — validation error
  • 401 — unauthenticated
  • 403 — access denied
  • 404 — resource not found
  • 422 — Unprocessable Entity (used by Laravel for validation)
  • 500 — internal server error

Idempotency

An idempotent request produces the same result when executed multiple times. PUT /users/1 with the same data should not create duplicates. This is critical for reliability on unstable networks.

Laravel Project Structure for APIs

Routes

All API routes are defined in routes/api.php. Laravel automatically adds the /api prefix and applies the api middleware.

// routes/api.php\nuse App\\Http\\Controllers\\Api\\V1\\ArticleController;\n\nRoute::prefix('v1')->middleware('auth:sanctum')->group(function () {\n    Route::apiResource('articles', ArticleController::class);\n    Route::apiResource('users.posts', PostController::class)->shallow();\n});

Controllers

Use the --api flag when generating controllers — it creates only the necessary methods, omitting create and edit.

php artisan make:controller Api/V1/ArticleController --api --model=Article

An example controller using API Resources:

<?php\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Http\\Controllers\\Controller;\nuse App\\Http\\Requests\\StoreArticleRequest;\nuse App\\Http\\Requests\\UpdateArticleRequest;\nuse App\\Http\\Resources\\ArticleResource;\nuse App\\Models\\Article;\n\nclass ArticleController extends Controller\n{\n    public function index()\n    {\n        $articles = Article::with('author')\n            ->latest()\n            ->paginate(15);\n\n        return ArticleResource::collection($articles);\n    }\n\n    public function store(StoreArticleRequest $request)\n    {\n        $article = Article::create($request->validated());\n\n        return new ArticleResource($article);\n    }\n\n    public function show(Article $article)\n    {\n        return new ArticleResource($article->load('author', 'tags'));\n    }\n\n    public function update(UpdateArticleRequest $request, Article $article)\n    {\n        $article->update($request->validated());\n\n        return new ArticleResource($article);\n    }\n\n    public function destroy(Article $article)\n    {\n        $article->delete();\n\n        return response()->noContent();\n    }\n}

API Resources

Never return Eloquent models directly. API Resources serve as a data transformation layer that protects against accidental field leaks and allows you to change the response structure independently of the model.

<?php\n\nnamespace App\\Http\\Resources;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass ArticleResource extends JsonResource\n{\n    public function toArray(Request $request): array\n    {\n        return [\n            'id'         => $this->id,\n            'title'      => $this->title,\n            'slug'       => $this->slug,\n            'content'    => $this->content,\n            'published'  => $this->published_at?->toIso8601String(),\n            'author'     => new UserResource($this->whenLoaded('author')),\n            'tags'       => TagResource::collection($this->whenLoaded('tags')),\n            'created_at' => $this->created_at->toIso8601String(),\n        ];\n    }\n}

Form Requests

Move validation into Form Requests — this keeps controllers lean and allows you to reuse validation rules:

<?php\n\nnamespace App\\Http\\Requests;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass StoreArticleRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        return $this->user()->can('create', Article::class);\n    }\n\n    public function rules(): array\n    {\n        return [\n            'title'   => ['required', 'string', 'max:255'],\n            'content' => ['required', 'string'],\n            'tags'    => ['array'],\n            'tags.*'  => ['integer', 'exists:tags,id'],\n        ];\n    }\n}

API Versioning: URI Versioning vs Header Versioning

This is one of the most debated topics in API design. There are two main approaches:

URI Versioning

GET /api/v1/articles\nGET /api/v2/articles

Pros: visible, easy to test in a browser, friendly for CDN-level caching. Cons: technically, URLs shouldn't contain protocol version information.

Header Versioning

GET /api/articles\nAccept: application/vnd.myapp.v2+json

Pros: "cleaner" from a REST purist perspective. Cons: harder to debug, doesn't work well with browser-based tools.

2026 Recommendation: use URI versioning for public and partner APIs — it's the pragmatic choice adopted by the vast majority of large companies (GitHub, Stripe, Twilio). Header versioning is only justified if you control all clients and can guarantee correct header propagation.

In Laravel, URI-based versioning is organized through folder structure:

app/Http/Controllers/Api/V1/ArticleController.php\napp/Http/Controllers/Api/V2/ArticleController.php\n\nroutes/api/v1.php\nroutes/api/v2.php

Authentication and Authorization: Laravel Sanctum vs Passport in 2026

Laravel Sanctum

Sanctum is the recommended solution for most projects in 2026. It supports API tokens and SPA authentication via cookie sessions. It's easy to set up and requires no OAuth server.

// Installation\ncomposer require laravel/sanctum\nphp artisan vendor:publish --provider=\"Laravel\\Sanctum\\SanctumServiceProvider\"\nphp artisan migrate\n\n// Issuing a token\n$token = $user->createToken('mobile-app', ['articles:read', 'articles:write']);\nreturn ['token' => $token->plainTextToken];\n\n// Checking token abilities\nif ($request->user()->tokenCan('articles:write')) {\n    // ...\n}

Laravel Passport

Passport implements a full OAuth 2.0 server. You need it when: you're issuing tokens to third-party applications, the Authorization Code Flow is required, or you're building a platform with a partner API.

Bottom line: if you have a mobile app or SPA — use Sanctum. If you're building an OAuth provider for third-party clients — use Passport.

Error Handling and Response Standardization

A consistent response format is critical for client-side developers. Configure a global exception handler in app/Exceptions/Handler.php:

<?php\n\nuse Illuminate\\Auth\\AuthenticationException;\nuse Illuminate\\Validation\\ValidationException;\nuse Symfony\\Component\\HttpKernel\\Exception\\HttpException;\n\n// Inside the register() method:\n$this->renderable(function (\\Throwable $e, $request) {\n    if ($request->expectsJson()) {\n        if ($e instanceof ValidationException) {\n            return response()->json([\n                'message' => 'Validation failed',\n                'errors'  => $e->errors(),\n            ], 422);\n        }\n\n        if ($e instanceof AuthenticationException) {\n            return response()->json([\n                'message' => 'Unauthenticated.',\n            ], 401);\n        }\n\n        if ($e instanceof HttpException) {\n            return response()->json([\n                'message' => $e->getMessage() ?: 'HTTP error',\n            ], $e->getStatusCode());\n        }\n\n        return response()->json([\n            'message' => 'Server error',\n        ], 500);\n    }\n});

For a standard successful response with pagination, Laravel automatically includes the data, links, and meta fields when using Resource::collection() with a paginated result — make use of this.

API Documentation with Scribe or L5-Swagger

Scribe

Scribe is a modern auto-documentation tool for Laravel. It analyzes routes, Form Requests, and docblock comments to generate beautiful HTML documentation and an OpenAPI specification.

composer require --dev knuckleswtf/scribe\nphp artisan vendor:publish --tag=scribe-config\nphp artisan scribe:generate

Annotate your controllers for better documentation:

/**\n * @group Articles\n *\n * API for managing articles\n */\nclass ArticleController extends Controller\n{\n    /**\n     * List articles\n     *\n     * Returns a paginated list of all published articles.\n     *\n     * @queryParam page integer Page number. Example: 1\n     * @queryParam per_page integer Items per page (max 50). Example: 15\n     */\n    public function index() { ... }\n}

L5-Swagger (darkaonline/l5-swagger)

If your team prefers OpenAPI 3.0 and Swagger UI, L5-Swagger remains a solid choice. Use PHP attributes instead of annotation comments — they are type-safe and fully supported by IDEs.

composer require darkaonline/l5-swagger\nphp artisan vendor:publish --provider \"L5Swagger\\L5SwaggerServiceProvider\"\nphp artisan l5-swagger:generate

Tip: integrate documentation generation into your CI/CD pipeline to ensure the docs always reflect the current codebase.

Testing REST APIs in Laravel

A well-tested API is an API you can trust. Laravel provides powerful feature testing tools out of the box.

<?php\n\nnamespace Tests\\Feature\\Api\\V1;\n\nuse App\\Models\\Article;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass ArticleApiTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_authenticated_user_can_create_article(): void\n    {\n        $user = User::factory()->create();\n\n        $response = $this->actingAs($user, 'sanctum')\n            ->postJson('/api/v1/articles', [\n                'title'   => 'Test Article',\n                'content' => 'Some content here',\n            ]);\n\n        $response\n            ->assertStatus(201)\n            ->assertJsonStructure([\n                'data' => ['id', 'title', 'slug', 'content', 'created_at'],\n            ])\n            ->assertJsonPath('data.title', 'Test Article');\n\n        $this->assertDatabaseHas('articles', ['title' => 'Test Article']);\n    }\n\n    public function test_unauthenticated_request_returns_401(): void\n    {\n        $this->postJson('/api/v1/articles', ['title' => 'Test'])\n            ->assertStatus(401);\n    }\n\n    public function test_validation_returns_422_with_errors(): void\n    {\n        $user = User::factory()->create();\n\n        $this->actingAs($user, 'sanctum')\n            ->postJson('/api/v1/articles', [])\n            ->assertStatus(422)\n            ->assertJsonValidationErrors(['title', 'content']);\n    }\n\n    public function test_article_list_is_paginated(): void\n    {\n        $user = User::factory()->create();\n        Article::factory()->count(20)->create();\n\n        $this->actingAs($user, 'sanctum')\n            ->getJson('/api/v1/articles')\n            ->assertStatus(200)\n            ->assertJsonStructure([\n                'data', 'links', 'meta' => ['total', 'per_page', 'current_page'],\n            ]);\n    }\n}

Run tests with the --parallel flag to speed up execution in CI:

php artisan test --parallel --coverage

Conclusion: Production-Ready API Checklist

Before considering your API ready for production, go through this checklist:

  1. Routes: use plural nouns, versioned via URI (/api/v1/)
  2. HTTP methods: used semantically — GET, POST, PUT/PATCH, DELETE
  3. Status codes: 201 for creation, 204 for deletion, 422 for validation, 401/403 for auth errors
  4. API Resources: all responses go through Resource transformers, models are never returned directly
  5. Form Requests: validation and authorization are extracted from controllers
  6. Authentication: Sanctum configured (or Passport for OAuth) with proper scopes/abilities
  7. Error handling: global handler returns JSON responses for all exceptions
  8. Versioning: routes and controllers are organized by version
  9. Documentation: generated via Scribe or L5-Swagger, integrated into CI
  10. Tests: happy path, validation errors, and auth scenarios are covered
  11. Rate Limiting: configured via RateLimiter::for() in RouteServiceProvider
  12. N+1 issues: resolved through eager loading, Laravel Debugbar or Telescope connected for monitoring

A high-quality REST API with Laravel is not just about correct code — it's about thoughtful architecture, predictable behavior, and solid documentation. Invest time in design: it will pay off many times over as you scale and grow your team.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →