Quick Start — Get Laravel Running in 5 Minutes
Six months ago I shipped a Laravel-backed SaaS product to production. Before that I had reservations — PHP’s reputation as a language people grow out of made me hesitant. After running it under real load, those doubts are gone. Here’s the exact setup that worked.
Start with Docker so your dev environment matches production from day one:
# Install Laravel via Composer
composer create-project laravel/laravel my-api
cd my-api
# Or use the Laravel installer
composer global require laravel/installer
laravel new my-api --git
Before writing a single controller, drop this docker-compose.yml at the project root:
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- .:/var/www/html
depends_on:
- db
environment:
DB_HOST: db
DB_DATABASE: myapp
DB_USERNAME: root
DB_PASSWORD: secret
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: myapp
volumes:
- db_data:/var/lib/mysql
redis:
image: redis:alpine
volumes:
db_data:
Add a minimal Dockerfile:
FROM php:8.2-fpm-alpine
RUN apk add --no-cache \
nginx \
curl \
git \
&& docker-php-ext-install pdo pdo_mysql redis
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader
EXPOSE 8000
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
Spin it up:
docker-compose up -d
docker-compose exec app php artisan migrate
You now have a running Laravel app with MySQL and Redis — the same stack I have in production.
Deep Dive — Building the RESTful API
Resource Controllers and Routes
Laravel’s resource routing eliminates boilerplate. One command generates the full CRUD scaffold:
php artisan make:model Post -mcr
# -m = migration, -c = controller, -r = resource controller
Register the resource in routes/api.php:
<?php
use App\Http\Controllers\PostController;
Route::apiResource('posts', PostController::class);
// Generates: GET /posts, POST /posts, GET /posts/{id},
// PUT /posts/{id}, DELETE /posts/{id}
Inside app/Http/Controllers/PostController.php, the index method with pagination looks like this:
public function index()
{
$posts = Post::with('author')
->latest()
->paginate(15);
return response()->json([
'data' => $posts->items(),
'meta' => [
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
]
]);
}
User Authentication with Laravel Sanctum
For token-based API auth, Sanctum is the right tool — lighter than Passport, zero OAuth complexity:
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Add the HasApiTokens trait to your User model:
<?php
namespace App\Models;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use HasApiTokens;
}
Create an AuthController:
php artisan make:controller AuthController
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
return response()->json([
'token' => $user->createToken('api')->plainTextToken,
'user' => $user,
], 201);
}
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
]);
}
return response()->json([
'token' => $user->createToken('api')->plainTextToken,
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
Wire up the auth routes in routes/api.php:
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('posts', PostController::class);
});
Advanced Usage — Queues, API Resources, and Rate Limiting
Transforming Responses with API Resources
Raw Eloquent models expose columns you don’t want clients to see. API Resources act as a transformation layer:
php artisan make:resource PostResource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'author' => $this->whenLoaded('author', fn() => [
'name' => $this->author->name,
'email' => $this->author->email,
]),
'created_at' => $this->created_at->toIso8601String(),
];
}
}
Background Jobs with Redis Queues
The moment you add email notifications or third-party webhooks, move them off the request cycle. With Redis already in the compose stack:
php artisan make:job SendWelcomeEmail
// In your register method, replace direct mail call:
SendWelcomeEmail::dispatch($user)->onQueue('emails');
// Start the worker inside the container
php artisan queue:work redis --queue=emails --tries=3
I have applied this approach in production and the results have been consistently stable — even under burst traffic where 200+ registrations hit simultaneously, not a single welcome email was dropped.
Rate Limiting
Laravel’s throttle middleware protects endpoints with two lines:
// In RouteServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Apply to route group
Route::middleware(['auth:sanctum', 'throttle:api'])->group(...);
Practical Tips From Six Months in Production
Environment Parity
The biggest source of production surprises is environment drift. Always build the Docker image and run it locally before pushing — never rely on php artisan serve alone. Add this to your CI pipeline:
docker build -t my-api:test .
docker run --rm my-api:test php artisan test
Database Query Optimization
Use ->with() aggressively to avoid N+1 queries. Install Laravel Debugbar during development to catch them before they reach staging:
composer require barryvdh/laravel-debugbar --dev
For production, enable query logging in staging and look for anything over 100ms:
DB::listen(function ($query) {
if ($query->time > 100) {
Log::warning('Slow query', [
'sql' => $query->sql,
'time' => $query->time,
]);
}
});
Health Check Endpoint
Docker and load balancers need a health endpoint. Add one that checks real dependencies:
Route::get('/health', function () {
DB::connection()->getPdo(); // throws if DB is down
return response()->json(['status' => 'ok']);
});
Keeping Secrets Out of Images
Never bake .env into the Docker image. Use Docker secrets or environment variables injected at runtime:
# docker-compose.yml — reference external env file
env_file:
- .env.production
The combination of Laravel’s expressive syntax, Sanctum’s no-nonsense token auth, and Docker’s reproducible environments removes most of the friction from API development. The stack is mature, the documentation is excellent, and the community has solved every problem you’re likely to encounter. What surprised me most after six months was how rarely I had to fight the framework — it mostly stayed out of the way.

