Back to blog
Laravel
Intermediate

Building a Scalable Laravel REST API with Docker & PostgreSQL

This comprehensive tutorial walks you through creating a production‑grade Laravel REST API, containerised with Docker and backed by PostgreSQL. You will master architecture decisions, real‑world patterns, and deployment strategies that scale.

August 27, 202519 min read

Introduction

Building a REST API that can handle growth from a few hundred requests per day to millions requires more than just writing controllers. You need a reproducible environment, a database that scales, and deployment pipelines that keep downtime to a minimum. Laravel, Docker, and PostgreSQL form a proven stack that satisfies all three requirements while keeping developer experience pleasant.

In this guide you will learn how to scaffold a fresh Laravel project, containerise every service with Docker Compose, configure PostgreSQL for performance, implement token‑based authentication with Sanctum, write clean resource classes, and prepare the application for zero‑downtime deployments. Each section includes production‑ready code snippets, architectural diagrams described in text, and a comparison of alternative tooling so you can make informed decisions.

Table of Contents

Core Concepts

Before diving into commands, it helps to understand the three pillars of this stack. Laravel provides an expressive MVC framework with built‑in support for API resources, validation, and queue workers. Docker packages the PHP runtime, extensions, Composer, and Node tooling into immutable images so every developer and CI runner sees the exact same environment. PostgreSQL offers advanced indexing, JSONB support, and robust concurrency control that out‑performs MySQL on write‑heavy API workloads.

API‑first design means every route returns JSON, uses proper HTTP status codes, and follows RESTful naming conventions. Laravel Sanctum supplies a lightweight token system that works for single‑page applications and mobile clients without the overhead of OAuth2. Docker Compose orchestrates the web, queue, scheduler, and database containers locally, while the same images can be pushed to a registry for Kubernetes or ECS deployments.

Architecture Overview

The high‑level diagram consists of four primary services: nginx (reverse proxy, TLS termination), php‑fpm (Laravel application), postgres (primary data store), and redis (cache, session, queue). A fifth optional service, worker, runs php artisan queue:work for background jobs such as sending notification emails or processing webhooks.

Network communication stays inside a user‑defined Docker network called api-network. Nginx listens on ports 80 and 443, forwards PHP requests to the php‑fpm container via FastCGI on port 9000, and serves static assets directly from the public folder. PostgreSQL persists data in a named volume pgdata so container restarts never lose migrations. Redis uses a separate volume redisdata for durability of queued jobs.

Step‑by‑Step Guide

1. Scaffold the Laravel Project

Run the following command on your host (Docker must be installed). It creates a fresh Laravel 11 installation inside a temporary container, then copies the files to your working directory.

docker run --rm -v $(pwd):/app -w /app composer:2 create-project laravel/laravel . "11.*"

2. Create the Docker Compose File

Place a docker-compose.yml at the project root. The file defines all services, volumes, and networks. Use the php:8.3-fpm-alpine base image and install required extensions (pdo_pgsql, redis, gd, zip).

version: '3.9'
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
      - ./public:/var/www/html/public
    depends_on:
      - php
    networks:
      - api-network

  php:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    volumes:
      - .:/var/www/html
    environment:
      - APP_ENV=local
      - DB_HOST=postgres
      - DB_DATABASE=laravel
      - DB_USERNAME=laravel
      - DB_PASSWORD=secret
      - REDIS_HOST=redis
    depends_on:
      - postgres
      - redis
    networks:
      - api-network

  worker:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    command: php artisan queue:work --sleep=3 --tries=3
    volumes:
      - .:/var/www/html
    environment:
      - APP_ENV=local
      - DB_HOST=postgres
      - DB_DATABASE=laravel
      - DB_USERNAME=laravel
      - DB_PASSWORD=secret
      - REDIS_HOST=redis
    depends_on:
      - postgres
      - redis
    networks:
      - api-network

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=laravel
      - POSTGRES_USER=laravel
      - POSTGRES_PASSWORD=secret
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - api-network

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data
    networks:
      - api-network

volumes:
  pgdata:
  redisdata:

networks:
  api-network:
    driver: bridge

3. Add the PHP Dockerfile

Create docker/php/Dockerfile to extend the official image with the extensions Laravel expects.

FROM php:8.3-fpm-alpine

RUN apk add --no-cache \
    postgresql-dev \
    libzip-dev \
    libpng-dev \
    freetype-dev \
    libjpeg-turbo-dev \
    $PHPIZE_DEPS \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) pdo_pgsql pgsql zip gd bcmath opcache \
    && pecl install redis \
    && docker-php-ext-enable redis

WORKDIR /var/www/html
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

4. Configure Nginx

Add docker/nginx/conf.d/default.conf. This file proxies PHP requests to the php‑fpm service and serves static files directly.

server {
    listen 80;
    server_name localhost;
    root /var/www/html/public;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass php:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}

5. Initialise the Application

Start the stack and run the first migration.

docker compose up -d --build
# wait a few seconds for postgres to accept connections
docker compose exec php php artisan key:generate
docker compose exec php php artisan migrate

6. Install Sanctum for API Tokens

Sanctum is included in Laravel 11. Publish its configuration and run the migration.

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

7. Create API Resources and Controllers

Use php artisan make:resource UserResource and php artisan make:controller Api/UserController --api. Implement index, show, store, update, destroy methods returning JSON with proper HTTP codes.

8. Secure Routes with Sanctum Middleware

In routes/api.php wrap routes with auth:sanctum middleware.

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('users', UserController::class);
    Route::post('logout', [AuthController::class, 'logout']);
});

9. Add Health‑Check Endpoint

Kubernetes and Docker Swarm rely on a lightweight health endpoint.

Route::get('/health', fn () => response()->json(['status' => 'ok']));

10. Prepare for Production

Set APP_ENV=production, enable OPcache, run php artisan config:cache route:cache view:cache, and push the built images to your registry.

Real‑World Examples

Imagine a SaaS product that onboards thousands of tenants. Each tenant gets an isolated schema in the same PostgreSQL cluster. The API uses a tenant_id column on every model and a global scope to enforce data isolation. Queue workers process invoice generation, sending PDFs via a third‑party email service. Redis caches the tenant configuration so the middleware resolves the schema in < 2 ms.

Another scenario: a mobile chat application. Conversations are stored in a messages table with a JSONB column for optional metadata (reactions, read receipts). The API exposes a GET /api/conversations/{id}/messages?since=timestamp endpoint that leverages a PostgreSQL partial index on created_at for fast pagination. WebSocket broadcasting is handled by Laravel Echo Server running in a separate container, but the REST API remains the source of truth.

Production Code Examples

User Resource with Conditional Fields

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->when($request->user()->can('view-email', $this->resource), $this->email),
            'created_at' => $this->created_at?->toIso8601String(),
            'links' => [
                'self' => route('users.show', $this->id),
            ],
        ];
    }
}

Global Scope for Multi‑Tenancy

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenantId = app('current_tenant')->id ?? 0;
        $builder->where($model->getTable().'.tenant_id', $tenantId);
    }
}

Queue Job with Retry Logic

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Services\InvoiceGenerator;

class GenerateInvoice implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $backoff = [60, 180, 300]; // seconds

    public function __construct(public int $orderId) {}

    public function handle(InvoiceGenerator $generator): void
    {
        $generator->createPdf($this->orderId);
    }
}

Comparison Table

AspectDocker + LaravelVagrant + HomesteadLocal PHP + Valet
Environment parityIdentical images across dev, CI, prodVM snapshots drift over timeHost‑specific PHP version
Spin‑up time~30 s (cached layers)2‑5 min (VM boot)Instant (native)
Resource usageLow (containers share kernel)High (full VM)Lowest
Production readinessImages promoted directlyRequires separate build stepManual artifact creation
Team onboardingOne docker compose upVagrant + VirtualBox installMac‑only, brew dependencies

Best Practices

  • Keep Dockerfiles thin: install only required extensions, clean apt cache in the same RUN layer.
  • Use multi‑stage builds for production images – copy only vendor and compiled assets.
  • Store secrets in Docker secrets or a vault; never bake .env into the image.
  • Run migrations in a dedicated init container or as a Kubernetes Job, not in the web pod.
  • Leverage Laravel Octane (Swoole or RoadRunner) for high‑throughput APIs; configure worker count per CPU core.
  • Version your API in the URL (/api/v1/) and maintain a deprecation policy.
  • Write contract tests with Pest or PHPUnit that hit the JSON endpoints, not just unit tests.

Common Mistakes

  • Running composer install inside the running container on every start – use a build‑time layer.
  • Binding PostgreSQL port 5432 to the host in docker-compose.yml – exposes the DB to the network.
  • Forgetting to set APP_KEY in production, causing session and encryption failures.
  • Using latest tags for base images – leads to unexpected breaking changes.
  • Placing business logic in controllers instead of actions/services, making testing harder.
  • Neglecting to configure OPcache opcache.validate_timestamps=0 in production.

Performance Tips

  • Enable PostgreSQL pg_stat_statements and monitor slow queries weekly.
  • Add composite indexes for frequent filter‑sort combinations (e.g., tenant_id, created_at DESC).
  • Cache expensive Eloquent queries with Cache::tags(['tenant:' . $tenantId]) for granular invalidation.
  • Use cursor() or chunkById for large exports to keep memory low.
  • Offload image resizing and PDF generation to the queue; respond 202 Accepted immediately.
  • Configure Nginx gzip and expires headers for static assets.
  • Profile with Blackfire or Xdebug in a staging container before each release.

Security Considerations

  • Enforce HTTPS in Nginx with a valid TLS certificate (Let's Encrypt or managed cert).
  • Set Secure and HttpOnly flags on cookies; Sanctum handles this automatically when SESSION_SECURE_COOKIE=true.
  • Implement rate limiting per IP and per authenticated user (RateLimiter::for('api', ...)).
  • Validate all incoming data with FormRequests; never trust $request->all() directly.
  • Rotate API tokens regularly; provide a POST /api/tokens/rotate endpoint.
  • Run containers as non‑root user (add user: "1000:1000" in compose for php service).
  • Scan images with Trivy or Snyk in CI pipeline.

Deployment Notes

For Kubernetes, create a Deployment for the php‑fpm image, a Service of type ClusterIP, and an Ingress that terminates TLS. Use a ConfigMap for non‑secret env vars and a Secret for APP_KEY, DB credentials, and Redis password. Enable HorizontalPodAutoscaler based on CPU and custom metrics (queue length).

If you prefer a managed platform, push the same images to AWS ECR and define an ECS Fargate service with an Application Load Balancer. Attach an EFS volume for Laravel storage if you need shared storage/app/public across tasks.

Blue‑green or rolling updates are trivial because the image is immutable. Run php artisan migrate --force as a pre‑deploy hook (Kubernetes initContainer or ECS task definition dependsOn).

Debugging Tips

  • Attach to the php container with docker compose exec php bash and run php artisan tinker for interactive debugging.
  • Enable Xdebug in the Dockerfile (add pecl install xdebug && docker-php-ext-enable xdebug) and configure xdebug.mode=debug,develop only for local profile.
  • Use docker compose logs -f php to tail Laravel logs in real time.
  • Inspect PostgreSQL query plans with EXPLAIN ANALYZE inside psql (run docker compose exec postgres psql -U laravel laravel).
  • Laravel Telescope can be installed in a dev container to visualise requests, queries, jobs, and exceptions.

FAQ

Why choose PostgreSQL over MySQL for a Laravel API?

PostgreSQL offers superior JSONB support, richer index types (GIN, GiST), and better concurrency handling under heavy write loads, which are common in API‑centric applications.

Can I run Laravel Octane inside the same Docker image?

Yes. Install Swoole or RoadRunner via PECL in the Dockerfile, then start the server with php artisan octane:start --server=swoole --host=0.0.0.0 --port=8000. Adjust the nginx config to proxy to port 8000.

How do I handle database migrations in a zero‑downtime deployment?

Run migrations before the new pods receive traffic. In Kubernetes use an initContainer or a separate Job with --force. Ensure migrations are backward compatible (add columns, never drop).

What is the recommended way to store uploaded files in a containerised setup?

Use an object store (S3, GCS, MinIO) via Laravel's FUSE driver. Mounting a shared volume works for single‑node but does not scale horizontally.

Do I need a separate queue worker container?

Highly recommended. Isolating workers prevents long‑running jobs from starving the web process and allows independent scaling.

How can I test the API locally without a full Kubernetes cluster?

Docker Compose replicates the production stack locally. Run docker compose up -d and hit http://localhost/api/health.

Is it safe to commit the docker-compose.yml with default passwords?

Never commit real secrets. Use a .env.docker file referenced by env_file in compose, and add it to .gitignore.

What monitoring stack works well with this architecture?

Prometheus + Grafana for metrics (expose /metrics via a Laravel exporter), Loki for logs, and Tempo for traces. All can run as sidecars or separate services.

Conclusion

You now have a complete, production‑grade blueprint for a scalable Laravel REST API powered by Docker and PostgreSQL. By following the step‑by‑step guide, adopting the best practices, and avoiding the common pitfalls, you can ship reliable APIs that grow with your product. Start a new repository today, run docker compose up -d, and experience the confidence of a fully containerised workflow. If you found this guide useful, share it with your team and subscribe to the newsletter for more deep‑dive tutorials.