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
- Architecture Overview
- Step‑By‑Step Guide
- Real‑World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
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/jsonfor programmatic clients andapplication/hal+jsonorapplication/vnd.api+jsonfor 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.phpusing theapiResourcehelper. - 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 aswagger.jsonfile automatically. - Swagger UI mounted at
/api/docsfor interactive exploration.
Step‑By‑Step Guide
1. Set Up a Fresh Laravel Project
composer create-project laravel/laravel laravel-openapi-apicd laravel-openapi-apiMake 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/sanctumThe 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 migrate4. Create the Core Domain – Product
php artisan make:model Product -mcrThis 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 UpdateProductRequestPopulate 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/ProductRepository 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:generateThe 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
| Feature | Laravel Swagger (goldspecdigital) | L5‑Swagger (darkaonline) | Manual YAML |
|---|---|---|---|
| Automatic Annotation Generation | ✅ Uses PHPDoc attributes | ✅ Uses annotations | ❌ Requires hand‑written spec |
| OpenAPI 3.0 Support | Full | Partial (mostly 2.0) | Full (if you write it) |
| Integration with Laravel Resources | Seamless | Limited | Manual mapping |
| Live Docs UI | Swagger UI out of box | Swagger UI via route | Any UI you host |
| Community & Maintenance | Active (2024‑2026) | Less active | Community 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-corsand configure allowed origins. - Apply Rate Limiting per token or per IP using Laravel's
throttlemiddleware. - Cache Responses where appropriate with
Cache::rememberto reduce DB load. - Implement Idempotency for POST actions that must be repeatable (use
Idempotency-Keyheader). - Use HTTPS Everywhere – enforce via web server and Laravel's
ForceHttpsmiddleware.
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
.envand 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
gziporbrotli. - Monitor with Laravel Telescope or
Laravel Debugbarin 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
apithrottle 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‑Policyvia 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:
- 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"]- Use
php artisan migrate --forceduring the CI/CD pipeline after health checks. - Configure
php-fpmandnginxwith TLS termination (Let's Encrypt) and setclient_max_body_sizeappropriately. - Set Laravel's
APP_ENV=productionandAPP_DEBUG=false. - Run
php artisan schedule:runvia a cron job for any background tasks. - Enable
opcachein PHP for faster execution.
Debugging Tips
- Use
dd($variable)sparingly; preferLog::debug()for production safety. - Enable
APP_DEBUG=truelocally to see detailed stack traces. - Inspect the generated
swagger.jsonwithjqto verify schema correctness. - Leverage
php artisan route:list --jsonto confirm middleware stack. - When a request fails validation, the JSON response contains an
errorsobject – 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.