Guide: How to plan and run load tests on PHP monoliths with k6 and Docker Compose — step by step

By · Published on · 9 min read

Laptop screen with blurred code and reflection of a stuffed toy
Laptop with blurred code and a stuffed toy — technical look with no readable data. Photo: Daniil Komov / Pexels

This guide shows how to plan and run load tests on PHP monoliths with k6 and Docker Compose: define objectives and SLOs, model user journeys and RPS, create an isolated Compose test environment, write k6 scripts with checks and thresholds, and analyze results to prioritize fixes and automate runs.

Why load testing PHP monoliths requires care

In PHP monoliths there is rarely a single bottleneck. PHP‑FPM, the database, cache and I/O all compete for resources. Your test must stress the full request flow, not just a single route. Measure p95 and error rates, but also watch worker saturation and internal queues.

PHP‑FPM queues per process. If the pool is too small you’ll see high latency and timeouts. If it’s too large, memory pressure can lead to swapping. Plan failure hypotheses and saturation signals to guide the test.

Monitor the web server, PHP‑FPM and the database during the run. Having baseline metrics prevents wrong conclusions after a latency spike. See Expose PHP‑FPM metrics to Prometheus for an example of exposing PHP‑FPM metrics.

  • Pay attention to the PHP‑FPM pool size and DB connections.
  • Avoid testing only static GETs; include auth and write operations.
  • Collect metrics before, during and after the test.

Planning: objectives, risks and success criteria (SLOs)

Define the test objective before writing the first script. Look for observable criteria that support decisions. Without a target, any result seems acceptable but won’t guide improvements. Focus on critical business flows and seasonal events.

Create simple, verifiable SLOs. Don’t lock into rigid numbers; use ranges and realistic targets based on historical load and expected peaks.

  • Scope: routes, read/write operations and integrations.
  • Signals: p95 < target, HTTP error rate < x%, minimum throughput.
  • Risks: cache evictions, migrations, table locks, PHP GC.
  • Duration: warm‑up, steady phase, ramp‑down.
  • Deliverable: report with metrics and next actions.

Modeling realistic load: journeys, RPS and VUs

Translate user behavior into scenarios. Don’t split traffic equally between routes. Use a weighted mix and include think time. In e‑commerce, search and browsing dominate; checkout is a small share but critical.

Choose whether to model with virtual users (VUs) or arrival rate (RPS). For backends, ramping‑arrival‑rate typically better represents unpredictable concurrent hits.

  • Identify journeys: login, browse, search, checkout.
  • Estimate the mix: 70% reads, 25% navigation, 5% sensitive writes.
  • Apply think time to avoid unrealistic request loops.
  • Include warm‑up to stabilize caches and JIT.
  • Vary payloads and parameters to exercise cache misses.

Test environment with Docker Compose: isolation and reproducibility

Use Docker Compose to isolate the application and make tests reproducible. Configure a dedicated network, environment variables and dependencies. Don’t share the database with development to avoid contaminating data.

To limit CPU, memory and I/O on the host, complement Compose with cgroup v2 and systemd when needed. This simulates smaller machines and reveals degradation earlier. See the cgroup v2 + systemd guide for Docker on Debian for details.

  • Separate the Compose network for stable DNS (app → http://app:8080).
  • Run cache and database in their own containers.
  • Version docker‑compose.yml alongside k6 scripts.
  • Avoid sensitive data; use dedicated fixtures.
version: "3.9"
services:
  app:
    image: nginx:alpine
    ports:
      - "8080:8080"
    volumes:
      - ./app:/var/www/html:ro
    depends_on:
      - php
      - db
      - cache
  php:
    image: php:8.2-fpm
    volumes:
      - ./app:/var/www/html:ro
  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=app
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=app
    volumes:
      - db:/var/lib/postgresql/data
  cache:
    image: redis:7-alpine
  k6:
    image: grafana/k6:latest
    volumes:
      - ./k6:/scripts:ro
    depends_on:
      - app
    command: ["run", "/scripts/script.js"]
volumes:
  db: {}

Writing k6 scripts: scenarios, checks, thresholds

Structure the script into modules per journey. Use tags to distinguish transactions and routes. Checks validate responses; thresholds fail the test when targets are missed. Prefer ramping‑arrival‑rate to control RPS and keep think time explicit.

Parameterize BASE_URL via an environment variable so you can change the target without editing code. Use JSON datasets for varied inputs and to exercise cache misses predictably.

  • Use tags: { journey: "checkout", route: "/orders" }.
  • Apply checks for status and key response content.
  • Set thresholds on p95/p99 and error rate.
  • Separating scenarios by journey makes analysis easier.
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    browse: {
      executor: 'ramping-arrival-rate',
      startRate: 10, timeUnit: '1s',
      preAllocatedVUs: 50, maxVUs: 200,
      stages: [
        { target: 50, duration: '2m' },
        { target: 100, duration: '5m' },
        { target: 0, duration: '1m' }
      ],
      exec: 'browse',
    },
    checkout: {
      executor: 'constant-vus', vus: 20, duration: '5m',
      exec: 'checkout', startTime: '2m'
    }
  },
  thresholds: {
    http_req_duration: ['p(95)<400'],
    http_req_failed: ['rate<0.02']
  }
};

const BASE_URL = __ENV.BASE_URL || 'http://app:8080';

export function browse() {
  const res = http.get(`${BASE_URL}/products?query=test`);
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(1);
}

export function checkout() {
  const login = http.post(`${BASE_URL}/login`, { u: 'user', p: 'pass' });
  check(login, { 'login ok': (r) => r.status === 200 });
  const order = http.post(`${BASE_URL}/orders`, JSON.stringify({ sku: 'ABC', qty: 1 }), { headers: { 'Content-Type': 'application/json' }});
  check(order, { 'order 201': (r) => r.status === 201 });
  sleep(0.5);
}

Data, authentication and logs: without these the test deceives

Without realistic data you measure a happy cache, not the real system. Prepare fixtures, users and tokens with appropriate expirations. Generate varied payloads to exercise indexes and cardinality in the database. Log correlation IDs to link k6 requests to server logs.

Standardize a correlation header and include the ID in every k6 request. That speeds diagnosis. For end‑to‑end tracing, implement distributed tracing with OpenTelemetry in PHP.

  • Create representative synthetic users and data.
  • Refresh tokens to avoid false failures from expiration.
  • Include X‑Request‑ID on each call.
  • Parameterize by environment using .env files.

Execution, observability and CI automation

Run k6 via Docker Compose and keep everything versioned. During a run, watch CPU, memory, I/O, connections and queues. Plan warm‑up, steady plateau and ramp‑down. Export the summary to JSON to attach to reports and compare runs.

Collect infrastructure and application metrics in parallel. Use time windows to correlate arrival peaks with latency. See how to set up Prometheus and Grafana with Docker Compose for an example of observability wiring.

Automate: run short scenarios on every PR and long scenarios in nightly jobs. Store artifacts per run. This prevents regressions and completes the flow described in this guide.

  • Track p95/p99, throughput and error rate per route.
  • Watch PHP‑FPM saturation and DB connections.
  • Save k6 summaries and dashboards as evidence.
docker compose run --rm \
  -e BASE_URL=http://app:8080 \
  -v "$PWD/results:/results" \
  k6 run /scripts/script.js --summary-export=/results/summary.json

Interpreting results: from symptom to action

Turn metrics into hypotheses and next actions. Focus on what threatens availability: server errors and extreme latencies. Then treat hotspots that limit throughput. Validate changes with a short follow‑up test that repeats the same scenario.

Dig deeper when you see irregular latency spikes. The article on diagnosing latency spikes in PHP APIs can help identify common symptoms and root causes.

Metric Observed signal Possible actions
p95 http_req_duration high Queueing in PHP‑FPM or slow DB Increase workers, optimize queries, add route caching
http_req_failed > 2% 5xx errors during peaks Circuit breaker, per‑client limits, review timeouts
App CPU > 85% sustained CPU saturation Optimize code, JIT, opcache, scale CPU
High DB wait Locks and expensive scans Indexes, query plan tuning, reduce N+1 queries
Low cache hit ratio Latency caused by cache misses Efficient keys, TTL tuning and cache warming

Frequently asked questions

How many virtual users (VUs) should I use in k6?

It depends on the objective. To simulate an arrival rate prefer RPS executors like ramping‑arrival‑rate, adjusting the rate and the maximum VUs to meet your target. For session‑based scenarios, constant VUs work well.

Should I run load tests in production?

Avoid running load tests in production. They compete with real users and can cause incidents. Prefer a dedicated, representative environment with synthetic data and similar resource limits. If unavoidable, limit the rate.

What’s the difference between load, stress and soak tests?

Load tests validate realistic traffic targets. Stress tests push beyond normal to find the breaking point and recovery behavior. Soak tests maintain load for a long time to reveal memory leaks, growing queues and long‑term effects.

How do I choose p95 and throughput targets?

Use historical data and business expectations as a baseline. Define ranges per critical journey and account for external dependencies. Set progressive targets and review after architectural changes. Thresholds should reflect real system behavior.

Can I run k6 via Docker Compose in CI?

Yes. Version docker‑compose.yml and your k6 scripts and run them in CI jobs. Export the summary for analysis and fail the job when thresholds are violated. Use environment variables to point at test targets and parameterize data.

How do I generate realistic test data?

Create fixtures and datasets covering a variety of parameters, sizes and combinations. Generate synthetic users and orders with realistic keys, indexes and access patterns. Avoid sensitive data. Rotate tokens and clean state when needed.

Want to structure your load tests with k6 and Docker Compose?

If you need help modeling traffic, building the test environment and automating runs, let’s discuss the next steps for your scenario.

Talk about load testing

See also