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
- Introduction
- 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 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
| Aspect | Docker + Laravel | Vagrant + Homestead | Local PHP + Valet |
|---|---|---|---|
| Environment parity | Identical images across dev, CI, prod | VM snapshots drift over time | Host‑specific PHP version |
| Spin‑up time | ~30 s (cached layers) | 2‑5 min (VM boot) | Instant (native) |
| Resource usage | Low (containers share kernel) | High (full VM) | Lowest |
| Production readiness | Images promoted directly | Requires separate build step | Manual artifact creation |
| Team onboarding | One docker compose up | Vagrant + VirtualBox install | Mac‑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
vendorand compiled assets. - Store secrets in Docker secrets or a vault; never bake
.envinto 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 installinside 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_KEYin production, causing session and encryption failures. - Using
latesttags 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=0in production.
Performance Tips
- Enable PostgreSQL
pg_stat_statementsand 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
gzipandexpiresheaders 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
SecureandHttpOnlyflags on cookies; Sanctum handles this automatically whenSESSION_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/rotateendpoint. - 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 bashand runphp artisan tinkerfor interactive debugging. - Enable Xdebug in the Dockerfile (add
pecl install xdebug && docker-php-ext-enable xdebug) and configurexdebug.mode=debug,developonly for local profile. - Use
docker compose logs -f phpto tail Laravel logs in real time. - Inspect PostgreSQL query plans with
EXPLAIN ANALYZEinsidepsql(rundocker 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.