Building a Laravel REST API with Docker: From Zero to Production in 2024

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

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.

Share: