Back to blog
Web Development
Intermediate

A Deep Dive into Building Scalable REST APIs with Laravel and OpenAPI

Discover a comprehensive, step‑by‑step guide to building scalable REST APIs with Laravel and OpenAPI. From core concepts to production‑ready code, best practices, performance tuning, and security considerations—everything you need to create robust APIs that developers love.

July 13, 202625 min read

Introduction

In today's micro‑service‑centric world, a well‑designed REST API is the backbone of every modern application. Laravel, with its expressive syntax and rich ecosystem, makes API development a joy. Pairing Laravel with the OpenAPI Specification (formerly Swagger) gives you a single source of truth for both implementation and documentation, enabling faster onboarding, automated client generation, and tighter contract testing.

This article walks you through every stage of building a scalable, versioned, and well‑documented REST API using Laravel 11 and OpenAPI 3.0. We will cover core concepts, architecture, a step‑by‑step guide, production‑ready code, a comparison of key tooling, best practices, performance tips, security considerations, deployment, debugging, and a thorough FAQ.

Table of Contents

Core Concepts

Before we dive into code, let's align on the fundamental ideas that shape a robust API.

  • Resource‑Oriented Design: Treat each domain entity (User, Order, Product) as a resource with its own URI.
  • Statelessness: Every HTTP request contains all information needed to process it; the server does not store client context between requests.
  • Versioning: Keep the API contract stable for consumers by namespace versioning (e.g., /api/v1/).
  • OpenAPI Specification: A machine‑readable contract that describes endpoints, parameters, request/response schemas, authentication, and more.
  • Content Negotiation: Support both application/json for programmatic clients and application/hal+json or application/vnd.api+json for richer hypermedia when needed.
  • Rate Limiting & Throttling: Protect resources from abuse while providing predictable QoS.

Architecture Overview

The architecture we will implement follows a classic layered approach:

[HTTP] → [Routing] → [Form Request Validation] → [Controller] → [Service Layer] → [Repository] → [Eloquent Model] → [Database]                ↑          [OpenAPI Generator]                ↓           [Swagger UI]

Key components:

  • Routes defined in routes/api.php using the apiResource helper.
  • Form Requests for validation, keeping controllers thin.
  • Service Classes encapsulating business logic, making the code unit‑testable.
  • Repositories abstracting data access, allowing future swapping of MySQL for PostgreSQL or a NoSQL store without touching the service layer.
  • OpenAPI Generator (e.g., goldspecdigital/laravel-swagger) parses PHPDoc annotations and produces a swagger.json file automatically.
  • Swagger UI mounted at /api/docs for interactive exploration.

Step‑By‑Step Guide

1. Set Up a Fresh Laravel Project

composer create-project laravel/laravel laravel-openapi-apicd laravel-openapi-api

Make sure you have PHP 8.2+, Composer 2.x, and a MySQL 8 or MariaDB instance ready.

2. Install Required Packages

composer require goldspecdigital/laravel-swaggercomposer require spatie/laravel-query-buildercomposer require laravel/sanctum

The Swagger package reads annotations and generates an OpenAPI 3.0 document. spatie/laravel-query-builder helps you handle filtering, sorting, and includes in a clean way. Sanctum provides token‑based authentication suitable for APIs.

3. Configure Sanctum

// config/sanctum.php'guard' => ['api'],

Run the migration:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"php artisan migrate

4. Create the Core Domain – Product

php artisan make:model Product -mcr

This generates model, migration, controller, and a resource class. Adjust the migration:

// database/migrations/xxxx_xx_xx_create_products_table.phpSchema::create('products', function (Blueprint $table) {    $table->id();    $table->string('sku')->unique();    $table->string('name');    $table->text('description')->nullable();    $table->decimal('price', 10, 2);    $table->unsignedInteger('stock');    $table->timestamps();});

5. Define API Routes with Versioning

// routes/api.phpuse App\Http\Controllers\Api\V1\ProductController;Route::prefix('v1')->middleware('auth:sanctum')->group(function () {    Route::apiResource('products', ProductController::class);});

6. Add Form Request Validation

php artisan make:request StoreProductRequestphp artisan make:request UpdateProductRequest

Populate them with rules:

// app/Http/Requests/StoreProductRequest.phppublic function rules(){    return [        'sku' => 'required|string|unique:products,sku',        'name' => 'required|string|max:255',        'description' => 'nullable|string',        'price' => 'required|numeric|min:0',        'stock' => 'required|integer|min:0',    ];}

7. Implement Service and Repository Layers

mkdir -p app/Services/Productmkdir -p app/Repositories/Product

Repository example:

// app/Repositories/Product/ProductRepository.phpnamespace App\Repositories\Product;use App\Models\Product;use Illuminate\Database\Eloquent\Builder;class ProductRepository{    public function all(array $filters = []): Builder    {        $query = Product::query();        if (isset($filters['sku'])) {            $query->where('sku', $filters['sku']);        }        return $query;    }    public function find(int $id): ?Product    {        return Product::find($id);    }    public function create(array $data): Product    {        return Product::create($data);    }    public function update(Product $product, array $data): Product    {        $product->update($data);        return $product;    }    public function delete(Product $product): void    {        $product->delete();    }}

Service example:

// app/Services/Product/ProductService.phpnamespace App\Services\Product;use App\Repositories\Product\ProductRepository;use App\Models\Product;class ProductService{    protected $repo;    public function __construct(ProductRepository $repo)    {        $this->repo = $repo;    }    public function list(array $filters)    {        return $this->repo->all($filters)->paginate(15);    }    public function get(int $id): Product    {        return $this->repo->find($id);    }    public function create(array $data): Product    {        return $this->repo->create($data);    }    public function update(int $id, array $data): Product    {        $product = $this->repo->find($id);        return $this->repo->update($product, $data);    }    public function delete(int $id): void    {        $product = $this->repo->find($id);        $this->repo->delete($product);    }}

8. Wire Up the Controller

// app/Http/Controllers/Api/V1/ProductController.phpnamespace App\Http\Controllers\Api\V1;use App\Http\Controllers\Controller;use App\Http\Resources\ProductResource;use App\Http\Resources\ProductCollection;use App\Http\Requests\StoreProductRequest;use App\Http\Requests\UpdateProductRequest;use App\Services\Product\ProductService;use Illuminate\Http\Request;/** * @OA\Info(title="Product API", version="1.0.0") */class ProductController extends Controller{    protected $service;    public function __construct(ProductService $service)    {        $this->service = $service;    }    /**     * @OA\Get(     *   path="/api/v1/products",     *   summary="List products",     *   @OA\Parameter(name="page", in="query", @OA\Schema(type="integer")),     *   @OA\Response(response=200, description="Successful collection", @OA\JsonContent(ref="#/components/schemas/ProductCollection"))     * )     */    public function index(Request $request)    {        $filters = $request->only(['sku', 'price_min', 'price_max']);        $products = $this->service->list($filters);        return new ProductCollection($products);    }    /**     * @OA\Post(     *   path="/api/v1/products",     *   summary="Create product",     *   @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/StoreProductRequest")),     *   @OA\Response(response=201, description="Created", @OA\JsonContent(ref="#/components/schemas/Product"))     * )     */    public function store(StoreProductRequest $request)    {        $product = $this->service->create($request->validated());        return (new ProductResource($product))->response()->setStatusCode(201);    }    public function show($id)    {        $product = $this->service->get($id);        return new ProductResource($product);    }    public function update(UpdateProductRequest $request, $id)    {        $product = $this->service->update($id, $request->validated());        return new ProductResource($product);    }    public function destroy($id)    {        $this->service->delete($id);        return response(null, 204);    }}

9. Generate OpenAPI Documentation

Publish the Swagger config and run the generator:

php artisan vendor:publish --provider="GoldSpecDigital\LaravelSwagger\LaravelSwaggerServiceProvider"php artisan swagger:generate

The generated public/swagger.json can be served via Swagger UI. Add a route:

// routes/web.phpRoute::get('api/docs', function () {    return view('swagger-ui');});

Create a simple Blade view that loads Swagger UI from the CDN and points to /swagger.json.

Real‑World Examples

The following snippets show how the API can be consumed from various client languages.

JavaScript (Axios)

import axios from 'axios';const api = axios.create({  baseURL: 'https://api.example.com/api/v1',  headers: { Authorization: `Bearer ${token}` }});// List products with paginationapi.get('/products?page=2').then(res => console.log(res.data));// Create a productapi.post('/products', {  sku: 'ABC123',  name: 'Laravel T‑Shirt',  price: 29.99,  stock: 150}).then(res => console.log('Created', res.data));

Python (Requests)

import requestsBASE = 'https://api.example.com/api/v1'headers = {'Authorization': f'Bearer {TOKEN}'}# Get a single productr = requests.get(f'{BASE}/products/1', headers=headers)print(r.json())# Update stockpayload = {'stock': 200}requests.patch(f'{BASE}/products/1', json=payload, headers=headers)

cURL

curl -X GET 'https://api.example.com/api/v1/products?sku=ABC123' \  -H 'Authorization: Bearer $TOKEN' \  -H 'Accept: application/json'

Production Code Examples

Below are complete files ready for copying into a Laravel project.

app/Http/Resources/ProductResource.php

namespace App\Http\Resources;use Illuminate\Http\Resources\Json\JsonResource;/** * @OA\Schema( *   schema="Product", *   required={"id","sku","name","price","stock"}, *   @OA\Property(property="id", type="integer", example=1), *   @OA\Property(property="sku", type="string", example="ABC123"), *   @OA\Property(property="name", type="string", example="Laravel T‑Shirt"), *   @OA\Property(property="description", type="string", nullable=true), *   @OA\Property(property="price", type="number", format="float", example=29.99), *   @OA\Property(property="stock", type="integer", example=150), *   @OA\Property(property="created_at", type="string", format="date-time"), *   @OA\Property(property="updated_at", type="string", format="date-time") * ) */class ProductResource extends JsonResource{    public function toArray($request)    {        return [            'id' => $this->id,            'sku' => $this->sku,            'name' => $this->name,            'description' => $this->description,            'price' => $this->price,            'stock' => $this->stock,            'created_at' => $this->created_at->toIso8601String(),            'updated_at' => $this->updated_at->toIso8601String(),        ];    }}

app/Http/Resources/ProductCollection.php

namespace App\Http\Resources;use Illuminate\Http\Resources\Json\ResourceCollection;/** * @OA\Schema( *   schema="ProductCollection", *   @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Product")), *   @OA\Property(property="links", type="object"), *   @OA\Property(property="meta", type="object") * ) */class ProductCollection extends ResourceCollection{    public function toArray($request)    {        return $this->collection->transform(function ($product) {            return (new ProductResource($product))->toArray($request);        })->all();    }}

Comparison Table

FeatureLaravel Swagger (goldspecdigital)L5‑Swagger (darkaonline)Manual YAML
Automatic Annotation Generation✅ Uses PHPDoc attributes✅ Uses annotations❌ Requires hand‑written spec
OpenAPI 3.0 SupportFullPartial (mostly 2.0)Full (if you write it)
Integration with Laravel ResourcesSeamlessLimitedManual mapping
Live Docs UISwagger UI out of boxSwagger UI via routeAny UI you host
Community & MaintenanceActive (2024‑2026)Less activeCommunity driven

Best Practices

  • Version Your API Early – use /api/v1/ and keep the version in the OpenAPI spec.
  • Use Form Request Classes for validation – they keep controllers thin and centralize rules.
  • Separate Business Logic into services; keep controllers as orchestration layers.
  • Leverage Eloquent Resources to shape JSON responses consistently.
  • Document Every Endpoint – the Swagger generator only includes what you annotate.
  • Enable CORS Properly – use fruitcake/laravel-cors and configure allowed origins.
  • Apply Rate Limiting per token or per IP using Laravel's throttle middleware.
  • Cache Responses where appropriate with Cache::remember to reduce DB load.
  • Implement Idempotency for POST actions that must be repeatable (use Idempotency-Key header).
  • Use HTTPS Everywhere – enforce via web server and Laravel's ForceHttps middleware.

Common Mistakes

  • Mixing query‑string filters directly in the controller instead of a dedicated query builder.
  • Returning Eloquent models directly – leaks internal fields and hurts versioning.
  • Hard‑coding URLs in OpenAPI annotations; use Laravel's url() helper when possible.
  • Neglecting pagination on collection endpoints – leads to performance catastrophe.
  • Storing API keys in the codebase – always use .env and Laravel's config caching.
  • Skipping authentication on public routes – even read‑only data often needs access control.
  • Forgetting to update the spec after a change – results in out‑of‑sync docs.

Performance Tips

  • Eager Load Relationships with with() to prevent N+1 queries.
  • Use Database Indexes on columns frequently filtered (e.g., sku, price).
  • Cache Static Endpoints (e.g., product catalog) using Cache::tags('products').
  • Enable Query Caching at the MySQL level for read‑heavy workloads.
  • Return JSON Only – disable Blade view rendering for API routes to save memory.
  • Compress Responses – ensure the web server sends gzip or brotli.
  • Monitor with Laravel Telescope or Laravel Debugbar in staging.

Security Considerations

  • Sanctum Token Scoping – assign abilities like create-product, view-product.
  • Input Sanitization – Laravel automatically guards against SQL injection, but validate and cast inputs.
  • Rate Limiting – configure api throttle to 60 requests per minute per user.
  • Output Encoding – use Laravel's escaping helpers when returning HTML fragments.
  • Use Helmet‑style Headers – set X‑Content‑Type‑Options, X‑Frame‑Options, Content‑Security‑Policy via middleware.
  • Audit Token Revocation – provide an endpoint to delete tokens when a user logs out.
  • Logging – avoid logging sensitive payloads; mask passwords and secret fields.

Deployment Notes

When deploying to production, follow these steps:

  1. Build a Docker image that contains PHP, Composer, and the compiled assets. Example Dockerfile:
FROM php:8.2-fpm-alpineWORKDIR /var/wwwRUN apk add --no-cache git bashRUN docker-php-ext-install pdo_mysqlCOPY . .RUN composer install --optimize-autoloader --no-devRUN php artisan cache:clear && php artisan config:cache && php artisan route:cache && php artisan view:cacheEXPOSE 9000CMD ["php-fpm"]
  1. Use php artisan migrate --force during the CI/CD pipeline after health checks.
  2. Configure php-fpm and nginx with TLS termination (Let's Encrypt) and set client_max_body_size appropriately.
  3. Set Laravel's APP_ENV=production and APP_DEBUG=false.
  4. Run php artisan schedule:run via a cron job for any background tasks.
  5. Enable opcache in PHP for faster execution.

Debugging Tips

  • Use dd($variable) sparingly; prefer Log::debug() for production safety.
  • Enable APP_DEBUG=true locally to see detailed stack traces.
  • Inspect the generated swagger.json with jq to verify schema correctness.
  • Leverage php artisan route:list --json to confirm middleware stack.
  • When a request fails validation, the JSON response contains an errors object – handle it client‑side gracefully.

FAQ

Can I use Laravel Horizon with this API?

Yes. Horizon can manage queued jobs such as email notifications or heavy data imports that you trigger from API endpoints. Just dispatch a job from your service layer.

How do I generate client SDKs from the OpenAPI spec?

Tools like openapi-generator-cli can produce TypeScript, Python, Java, and Go SDKs directly from the swagger.json file.

Is Sanctum enough for third‑party consumer APIs?

Sanctum works well for first‑party mobile/web apps. For third‑party B2B integrations, consider Laravel Passport (OAuth2) or API keys with scoped permissions.

How can I test the API contract?

Use schemathesis or dredd to run contract tests against the live /swagger.json and ensure responses match the spec.

What database engines are supported?

Laravel's query builder works with MySQL, PostgreSQL, SQLite, and SQL Server. Choose based on scaling needs; PostgreSQL offers richer JSON support for future extensions.

How do I handle soft deletes?

Add the SoftDeletes trait to the model, update migrations with $table->softDeletes(); and expose a restore endpoint in the controller.

Can I stream large CSV exports?

Yes. Use Laravel's StreamedResponse to push data line‑by‑line, keeping memory usage low.

What pagination style does Laravel use?

By default, Laravel uses limit‑offset pagination with page and per_page query parameters. You can switch to cursor pagination for better performance on large tables.

Conclusion

Building a production‑grade REST API with Laravel and OpenAPI bridges the gap between rapid development and enterprise‑level reliability. By following the layered architecture, leveraging form requests, services, and repositories, and keeping your OpenAPI spec in sync, you deliver a contract that developers trust. Remember to version early, enforce security with Sanctum, cache judiciously, and monitor performance.Ready to put this into practice? Clone the repository, run the generator, and start serving live docs at /api/docs. Share your feedback in the comments, and stay tuned for our next deep dive on scaling Laravel micro‑services with Docker and Kubernetes.