A production-ready, API-only Laravel 12 starter kit following the 2024-2025 REST API ecosystem best practices. No frontend dependencies - purely headless API for mobile apps, SPAs, or microservices.
- API-Only - No Blade, Vite, or frontend assets
- Token Authentication - Laravel Sanctum for mobile/SPA auth
- Email Verification - Built-in email verification flow with signed URLs
- Password Reset - Secure password reset with token-based flow
- API Versioning - URI-based versioning with deprecation support via grazulex/laravel-apiroute
- Query Building - Filtering, sorting, includes via spatie/laravel-query-builder
- Data Objects - Type-safe DTOs via spatie/laravel-data
- Auto Documentation - Zero-annotation OpenAPI 3.1 via dedoc/scramble
- Modern Testing - Pest PHP with Laravel HTTP testing
- Code Quality - PHPStan (max level), Rector, and Pint with strict rules
- Rate Limiting - Configurable per-route rate limiters
- Reusable Middleware - ForceJsonResponse, LogApiRequests, EnsureEmailVerified
- Standardized Responses - Consistent JSON response format
- Optional: API Idempotency - RFC-compliant idempotency via grazulex/laravel-api-idempotency
- Optional: Smart Rate Limiting - Plan-aware throttling with quotas via grazulex/laravel-api-throttle-smart
- Docker & Docker Compose
- Or: PHP 8.3+, Composer 2.x
# Clone the repository
git clone https://github.com/grazulex/laravel-api-kit.git
cd laravel-api-kit
# Copy environment file
cp .env.example .env
# Build and start containers
docker compose build
docker compose up -d
# Install dependencies
docker compose run --rm app composer install
# Generate application key
docker compose run --rm app php artisan key:generate
# Run migrations
docker compose run --rm app php artisan migrate
# Run tests to verify installation
docker compose run --rm app ./vendor/bin/pest# Clone and install
git clone https://github.com/grazulex/laravel-api-kit.git
cd laravel-api-kit
composer install
# Configure
cp .env.example .env
php artisan key:generate
# Database (SQLite by default)
touch database/database.sqlite
php artisan migrate
# Verify
./vendor/bin/pestOnce running, access the auto-generated documentation:
- Swagger UI: http://localhost:8080/docs/api
- OpenAPI JSON: http://localhost:8080/docs/api.json
This kit uses Laravel Sanctum with token-based authentication (ideal for mobile apps and third-party API consumers).
curl -X POST http://localhost:8080/api/v1/register \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "password123",
"password_confirmation": "password123"
}'Response:
{
"success": true,
"message": "User registered successfully. Please check your email to verify your account.",
"data": {
"user": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"email_verified_at": null,
"created_at": "2025-01-15T10:30:00+00:00"
},
"token": "1|abc123..."
}
}curl -X POST http://localhost:8080/api/v1/login \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}'Include the token in the Authorization header for protected routes:
curl -X GET http://localhost:8080/api/v1/me \
-H "Authorization: Bearer 1|abc123..." \
-H "Accept: application/json"curl -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer 1|abc123..." \
-H "Accept: application/json"After registration, users receive a verification email. The kit integrates with Laravel's MustVerifyEmail contract.
Verify Email (via signed URL from email):
curl -X POST "http://localhost:8080/api/v1/email/verify/{id}/{hash}?signature=..." \
-H "Authorization: Bearer 1|abc123..." \
-H "Accept: application/json"Resend Verification Email:
curl -X POST http://localhost:8080/api/v1/email/resend \
-H "Authorization: Bearer 1|abc123..." \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "john@example.com"}'Response:
{
"success": true,
"message": "Verification email sent successfully",
"data": null
}Request Password Reset Link:
curl -X POST http://localhost:8080/api/v1/forgot-password \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "john@example.com"}'Response:
{
"success": true,
"message": "Password reset link sent to your email",
"data": null
}Reset Password (with token from email):
curl -X POST http://localhost:8080/api/v1/reset-password \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"email": "john@example.com",
"token": "reset-token-from-email",
"password": "newpassword123",
"password_confirmation": "newpassword123"
}'Response:
{
"success": true,
"message": "Password reset successfully",
"data": null
}Note: After a successful password reset, all user tokens are revoked for security.
| Method | Endpoint | Auth | Description | Rate Limit |
|---|---|---|---|---|
| POST | /register | No | Register new user | 5/min |
| POST | /login | No | Get authentication token | 5/min |
| POST | /logout | Yes | Revoke current token | 120/min |
| GET | /me | Yes | Get current user profile | 120/min |
| POST | /email/verify/{id}/{hash} | Yes | Verify email address | 120/min |
| POST | /email/resend | Yes | Resend verification email | 6/min |
| POST | /forgot-password | No | Request password reset link | 6/min |
| POST | /reset-password | No | Reset password with token | 6/min |
All API responses follow a consistent format:
{
"success": true,
"message": "Operation successful",
"data": {
// Response data here
}
}{
"success": false,
"message": "Error description",
"errors": {
"field": ["Validation error message"]
}
}| Code | Description |
|---|---|
| 200 | Success |
| 201 | Resource created |
| 204 | No content |
| 400 | Bad request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not found |
| 422 | Validation error |
| 429 | Too many requests |
| 500 | Server error |
laravel-api-kit/
├── app/
│ ├── Actions/ # Single-purpose action classes
│ ├── DTOs/ # Data Transfer Objects (spatie/laravel-data)
│ ├── Http/
│ │ ├── Controllers/
│ │ │ └── Api/
│ │ │ ├── ApiController.php # Base controller with ApiResponse
│ │ │ └── V1/ # Version 1 controllers
│ │ │ └── AuthController.php
│ │ ├── Requests/
│ │ │ └── Api/V1/ # Form Requests per version
│ │ │ ├── LoginRequest.php
│ │ │ └── RegisterRequest.php
│ │ └── Resources/ # API Resources
│ │ └── UserResource.php
│ ├── Models/
│ │ └── User.php # With HasApiTokens trait
│ ├── Providers/
│ │ └── AppServiceProvider.php # Rate limiting config
│ ├── Services/ # Business logic services
│ └── Traits/
│ └── ApiResponse.php # Standardized responses
├── config/
│ ├── apiroute.php # API versioning config
│ ├── cors.php # CORS settings
│ ├── sanctum.php # Token auth config
│ └── scramble.php # API docs config
├── routes/
│ ├── api.php # API routes entry point
│ └── api/
│ └── v1.php # Version 1 routes
├── tests/
│ └── Feature/Api/V1/
│ └── AuthTest.php # Authentication tests
├── docker-compose.yml
├── Dockerfile
└── CLAUDE.md # AI assistant instructions
This kit uses grazulex/laravel-apiroute v2.x for API versioning with support for:
- URI Path (default):
/api/v1/users,/api/v2/users - Header:
X-API-Version: 2 - Query Parameter:
?api_version=2 - Accept Header:
Accept: application/vnd.api.v2+json
- Create controllers in
app/Http/Controllers/Api/V2/ - Create requests in
app/Http/Requests/Api/V2/ - Create route file
routes/api/v2.php:
<?php
use App\Http\Controllers\Api\V2\AuthController;
use Illuminate\Support\Facades\Route;
Route::post('register', [AuthController::class, 'register']);
// ... more routes- Update
config/apiroute.php:
'versions' => [
'v1' => [
'routes' => base_path('routes/api/v1.php'),
'status' => 'deprecated',
'deprecated_at' => '2025-06-01',
'sunset_at' => '2025-12-01',
'successor' => 'v2',
],
'v2' => [
'routes' => base_path('routes/api/v2.php'),
'status' => 'active',
],
],When accessing deprecated versions, responses include RFC-compliant headers:
Deprecation: Sun, 01 Jun 2025 00:00:00 GMT
Sunset: Mon, 01 Dec 2025 00:00:00 GMT
Link: </api/v2>; rel="successor-version"Use spatie/laravel-query-builder for filtering, sorting, and including relationships:
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
// In your controller
$users = QueryBuilder::for(User::class)
->allowedFilters([
'name',
'email',
AllowedFilter::exact('id'),
AllowedFilter::scope('active'),
])
->allowedSorts(['name', 'created_at'])
->allowedIncludes(['posts', 'comments'])
->paginate();
return UserResource::collection($users);Request examples:
GET /api/v1/users?filter[name]=john
GET /api/v1/users?sort=-created_at
GET /api/v1/users?include=posts,comments
GET /api/v1/users?filter[name]=john&sort=name&include=posts
Use spatie/laravel-data for type-safe DTOs:
// app/DTOs/UserData.php
use Spatie\LaravelData\Data;
class UserData extends Data
{
public function __construct(
public string $name,
public string $email,
public ?string $password = null,
) {}
}
// In controller - validates and transforms automatically
public function store(UserData $data): JsonResponse
{
$user = User::create($data->toArray());
return $this->created(UserResource::make($user));
}Configured in app/Providers/AppServiceProvider.php:
| Limiter | Limit | Use Case |
|---|---|---|
api |
60/min | Default for all API routes |
auth |
5/min | Login/register (brute force protection) |
authenticated |
120/min | Logged-in users |
// In routes/api.php
Route::middleware('throttle:auth')->group(function () {
Route::post('login', [AuthController::class, 'login']);
Route::post('register', [AuthController::class, 'register']);
});
Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function () {
// Protected routes with higher limits
});Responses include rate limit information:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
Retry-After: 60 # When limit exceededThe following packages are suggested (not required) and can be installed individually to extend the kit's capabilities. They are fully opt-in and will not affect existing behavior.
grazulex/laravel-api-idempotency provides RFC-compliant idempotency for your API endpoints. It prevents duplicate operations when clients retry requests (critical for payments, order creation, etc.).
Install:
composer require grazulex/laravel-api-idempotencyPublish config (optional):
php artisan vendor:publish --tag="api-idempotency-config"Usage — apply the middleware to mutation routes:
// routes/api/v1.php
Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function () {
Route::post('orders', [OrderController::class, 'store'])
->middleware('idempotent');
Route::post('payments', [PaymentController::class, 'store'])
->middleware('idempotent:required'); // Require Idempotency-Key header
});Client-side — include the Idempotency-Key header:
curl -X POST http://localhost:8080/api/v1/orders \
-H "Authorization: Bearer 1|abc123..." \
-H "Idempotency-Key: order_unique_key_123" \
-H "Content-Type: application/json" \
-d '{"product_id": 1, "quantity": 2}'Attention:
- Only apply the
idempotentmiddleware to mutation routes (POST, PUT, PATCH). GET requests are naturally idempotent.- The default storage driver is
cache. For production with multiple servers, use theredisordatabasedriver.- Keys are scoped per user by default. Two different users can use the same key without conflict.
grazulex/laravel-api-throttle-smart provides plan-aware rate limiting with quotas, multiple algorithms (fixed window, sliding window, token bucket), and multi-tenant support. Ideal for SaaS APIs with subscription tiers.
Install:
composer require grazulex/laravel-api-throttle-smartPublish config:
php artisan vendor:publish --tag="throttle-smart-config"Usage — apply to routes where plan-based limiting is needed:
// routes/api/v1.php
Route::middleware(['auth:sanctum', 'throttle.smart'])->group(function () {
Route::apiResource('posts', PostController::class);
});Attention:
- This package coexists with Laravel's built-in
throttle:middleware. You do not need to remove the existing rate limiters.- If you want to replace the native throttle on specific routes, swap
throttle:authenticatedwiththrottle.smarton those routes only.- Do not apply both
throttle:authenticatedandthrottle.smarton the same route group — choose one per group to avoid double rate limiting.- The default driver is
cache. For production,redisis recommended for performance and distributed consistency.- Configure your subscription plans in
config/throttle-smart.phpto match your business model (Free, Pro, Enterprise, etc.).
The kit includes three production-ready middleware patterns that you can apply to your routes as needed.
| Alias | Class | Description |
|---|---|---|
force.json |
ForceJsonResponse |
Ensures all responses are JSON formatted |
log.api |
LogApiRequests |
Logs API requests with timing information |
verified |
EnsureEmailVerified |
Requires verified email to access route |
Automatically sets Accept: application/json header and converts non-JSON responses to JSON format.
Route::middleware('force.json')->group(function () {
// All responses will be JSON
});Logs API requests with detailed information and adds X-Response-Time header to responses.
Logged data: timestamp, method, URL, IP, user ID, status code, duration (ms), user agent.
Enable logging via environment:
APP_LOG_API_REQUESTS=trueRoute::middleware('log.api')->group(function () {
// Requests will be logged
});Protects routes that require a verified email address. Returns 403 if email is not verified.
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
// Only users with verified emails can access
});Response when email not verified:
{
"success": false,
"message": "Your email address is not verified. Please verify your email to continue."
}This kit uses Pest PHP for testing:
# Run all tests
docker compose run --rm app ./vendor/bin/pest
# Run specific test file
docker compose run --rm app ./vendor/bin/pest tests/Feature/Api/V1/AuthTest.php
# Run with coverage
docker compose run --rm app ./vendor/bin/pest --coverage
# Run in parallel
docker compose run --rm app ./vendor/bin/pest --parallel// tests/Feature/Api/V1/UserTest.php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('lists users for authenticated user', function () {
$user = User::factory()->create();
$token = $user->createToken('test')->plainTextToken;
User::factory()->count(5)->create();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/v1/users');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'*' => ['id', 'name', 'email']
]
]);
});
it('requires authentication', function () {
$this->getJson('/api/v1/users')
->assertStatus(401);
});This kit includes strict code quality tools configured following nunomaduro/laravel-starter-kit standards.
| Tool | Purpose | Config |
|---|---|---|
| PHPStan + Larastan | Static analysis (level max) | phpstan.neon |
| Rector | Automated refactoring | rector.php |
| Pint | Code style (strict rules) | pint.json |
# Apply all fixes (Rector + Pint)
composer lint
# Check without fixing (CI mode)
composer test:lint
# Static analysis only
composer test:types
# Unit tests only
composer test:unit
# Full test suite (lint + types + unit)
composer testdocker compose exec app composer lint
docker compose exec app composer testdeclare(strict_types=1)on all filesfinalclasses by default- Type declarations enforced
- Dead code removal
- Early returns
- Strict comparisons
Tests run automatically on push/PR to main via .github/workflows/tests.yml.
# List all routes
docker compose run --rm app php artisan route:list
# Clear all caches
docker compose run --rm app php artisan optimize:clear
# Generate IDE helper files (if using Laravel IDE Helper)
docker compose run --rm app php artisan ide-helper:generate
docker compose run --rm app php artisan ide-helper:models -N
# Export OpenAPI spec to file
docker compose run --rm app php artisan scramble:exportKey .env variables:
# Application
APP_NAME="Laravel API Kit"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8080
# Database (SQLite for development)
DB_CONNECTION=sqlite
DB_DATABASE=/var/www/database/database.sqlite
# For MySQL/PostgreSQL
# DB_CONNECTION=mysql
# DB_HOST=mysql
# DB_PORT=3306
# DB_DATABASE=laravel_api_kit
# DB_USERNAME=laravel
# DB_PASSWORD=secret
# Sanctum
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,127.0.0.1
# API Versioning
API_VERSION_STRATEGY=uri
API_DEFAULT_VERSION=latest
# Rate Limiting
API_RATE_LIMIT=60
# Documentation
API_DOCS_URL=http://localhost:8080/docs/api- Set
APP_ENV=productionandAPP_DEBUG=false - Configure proper database (MySQL/PostgreSQL)
- Set
APP_URLto your production URL - Configure
SANCTUM_STATEFUL_DOMAINSfor your frontend domains - Review and tighten CORS settings in
config/cors.php - Set up proper rate limiting for production load
- Configure caching (Redis recommended)
- Set up queue worker for background jobs
- Enable HTTPS and update URLs
# Example production Dockerfile additions
FROM php:8.3-fpm-alpine
# Install opcache for performance
RUN docker-php-ext-install opcache
# Production PHP settings
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/
COPY docker/php/php.ini /usr/local/etc/php/conf.d/- Create Model & Migration:
docker compose run --rm app php artisan make:model Post -m- Create Controller:
// app/Http/Controllers/Api/V1/PostController.php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\ApiController;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Spatie\QueryBuilder\QueryBuilder;
class PostController extends ApiController
{
public function index()
{
$posts = QueryBuilder::for(Post::class)
->allowedFilters(['title', 'status'])
->allowedSorts(['title', 'created_at'])
->allowedIncludes(['author', 'comments'])
->paginate();
return $this->success(PostResource::collection($posts));
}
public function show(Post $post)
{
return $this->success(new PostResource($post));
}
// ... store, update, destroy methods
}- Create Resource:
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content,
'author' => new UserResource($this->whenLoaded('author')),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}- Add Routes:
// routes/api/v1.php
Route::middleware('auth:sanctum')->group(function () {
// ... existing routes
Route::apiResource('posts', PostController::class);
});- Create Tests:
// tests/Feature/Api/V1/PostTest.php
uses(RefreshDatabase::class);
it('lists posts', function () {
$user = User::factory()->create();
Post::factory()->count(3)->create();
$this->actingAs($user)
->getJson('/api/v1/posts')
->assertStatus(200)
->assertJsonCount(3, 'data');
});- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is open-sourced software licensed under the MIT license.
- Laravel - The PHP Framework
- Laravel Sanctum - API Token Authentication
- grazulex/laravel-apiroute - API Versioning
- spatie/laravel-query-builder - Query Building
- spatie/laravel-data - Data Transfer Objects
- dedoc/scramble - API Documentation
- grazulex/laravel-api-idempotency - API Idempotency (optional)
- grazulex/laravel-api-throttle-smart - Smart Rate Limiting (optional)
- Pest PHP - Testing Framework