← Back to blog

Realistic API Performance Testing for Laravel/Next.js with k6 and Playwright

2026-10-07

Why “Pass” Is Not Enough: The Agent’s Blind Spot

Imagine you just asked your AI‑driven test agent, “Did the checkout API survive the spike?” The agent replies, “All tests passed.” You celebrate, push the code, and the next minute a customer reports a 5‑second checkout latency. The agent’s answer was technically correct – every assertion in the test script succeeded – but it never warned you that the API would crumble under realistic traffic.

Transferable rule: Never rely on a single “pass/fail” signal. Correlate load‑generated response times with server‑side metrics (CPU, DB query time, queue depth) before you deem a performance test successful. This rule applies whether you’re testing a Laravel monolith or a Next.js edge API.

Below we’ll build a minimal, reproducible setup that lets your agent say “All good” only after it has verified both client‑side latency and backend health. The stack is:

Setting Up a Self‑Contained Docker Lab

First, create a docker-compose.yml that spins up three containers:

All services share a network: testnet so they can address each other by service name.

<pre><code>version: '3.9'

services:
  laravel:
    image: php:8.2-cli
    working_dir: /var/www
    volumes:
      - ./laravel:/var/www
    command: sh -c "composer install && php artisan serve --host=0.0.0.0 --port=8000"
    ports:
      - "8000:8000"
    networks:
      - testnet

  nextjs:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./nextjs:/app
    command: sh -c "npm install && npm run dev -- --port 3000"
    ports:
      - "3000:3000"
    networks:
      - testnet

  k6:
    image: loadimpact/k6:latest
    depends_on:
      - laravel
      - nextjs
    networks:
      - testnet
    volumes:
      - ./k6:/scripts
    entrypoint: ["/bin/sh", "-c"]
    command: "while true; do sleep 3600; done"

networks:
  testnet:
    driver: bridge</code></pre>

Save the file, then run docker compose up -d. The three containers are now ready for any agent‑driven invocation.

Writing a Realistic k6 Scenario

k6 scripts are plain JavaScript. The key to realism is to model the traffic pattern you actually see in production. For a checkout flow the pattern often looks like:

Here’s a compact script that does exactly that and also records server‑side metrics via a custom endpoint (/metrics) exposed by Laravel and Next.js.

<pre><code>import http from 'k6/http';
import { sleep, check } from 'k6';
import { Trend, Rate } from 'k6/metrics';

// Custom metrics
export const apiLatency = new Trend('api_latency');
export const serverCpu = new Trend('server_cpu');
export const errorRate = new Rate('error_rate');

export const options = {
  stages: [
    { duration: '30s', target: 20 }, // burst
    { duration: '2m', target: 5 },   // steady
    { duration: '30s', target: 0 }, // cool‑down
  ],
  thresholds: {
    apiLatency: ['p(95)<800'], // 95% of requests < 800 ms
    errorRate: ['rate<0.01'],  // <1 % errors
  },
};

function fetchMetrics(service) {
  const res = http.get(`http://${service}:8000/metrics`);
  if (res.status === 200) {
    const data = JSON.parse(res.body);
    serverCpu.add(data.cpu);
  }
}

export default function () {
  // 1️⃣ Hit Laravel checkout endpoint
  let laravelRes = http.post('http://laravel:8000/api/checkout', {
    cart_id: Math.floor(Math.random() * 1000),
    payment_method: 'card',
  });
  apiLatency.add(laravelRes.timings.duration);
  check(laravelRes, { 'laravel status 200': (r) => r.status === 200 });
  errorRate.add(laravelRes.status !== 200);
  fetchMetrics('laravel');

  // 2️⃣ Hit Next.js order‑summary API (edge)
  let nextRes = http.get('http://nextjs:3000/api/order-summary?cart_id=123');
  apiLatency.add(nextRes.timings.duration);
  check(nextRes, { 'nextjs status 200': (r) => r.status === 200 });
  errorRate.add(nextRes.status !== 200);
  fetchMetrics('nextjs');

  sleep(1);
}</code></pre>

Notice the fetchMetrics call after each request. Your Laravel and Next.js apps must expose a lightweight /metrics endpoint that returns JSON like { "cpu": 0.42 }. This is the “correlate” part of the rule: the agent can now assert not only that the HTTP response was fast, but also that the server was not saturated.

Deploying the Metrics Endpoint in Laravel

In routes/api.php add a simple route:

<pre><code>use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;

Route::get('/metrics', function () {
    $cpu = sys_getloadavg()[0] / sys_getconf('SC_NPROCESSORS_ONLN');
    $queries = DB::connection()->getQueryLog();
    $dbTime = array_sum(array_column($queries, 'time')) / 1000; // seconds

    return response()->json([
        'cpu' => round($cpu, 2),
        'db_time' => round($dbTime, 3),
    ]);
});</code></pre>

The endpoint is intentionally cheap – it just reads the current load average and aggregates the last query log. It’s safe to call on every k6 iteration.

Deploying the Metrics Endpoint in Next.js

Create pages/api/metrics.js:

<pre><code>export default async function handler(req, res) {
  const { cpuUsage } = await import('os');
  const cpu = cpuUsage().user / 1e9; // convert nanoseconds to seconds

  // Simulate a quick DB check – in real code you’d query your DB here.
  const dbTime = Math.random() * 0.02; // 0‑20 ms

  res.status(200).json({ cpu: Number(cpu.toFixed(2)), db_time: dbTime });
}</code></pre>

Again, the endpoint is lightweight. The agent will call it after each API request, keeping the latency budget honest.

Playwright for Functional Confirmation

k6 tells you “the system can handle X RPS”. Playwright tells you “the API still returns the right shape”. Combine them in a single Docker container so the agent can fire both with a single CLI call.

Folder playwright contains:

<pre><code># Dockerfile
FROM mcr.microsoft.com/playwright:latest

WORKDIR /tests
COPY package*.json ./
RUN npm ci
COPY . .

CMD ["npx", "playwright", "test"]</code></pre>

Playwright test:

<pre><code>// tests/checkout.spec.js
const { test, expect } = require('@playwright/test');

test('checkout returns orderId and total', async ({ request }) => {
  const cartId = Math.floor(Math.random() * 1000);
  const response = await request.post('http://laravel:8000/api/checkout', {
    data: { cart_id: cartId, payment_method: 'card' },
  });
  expect(response.ok()).toBeTruthy();
  const body = await response.json();
  expect(body).toHaveProperty('order_id');
  expect(body).toHaveProperty('total');
});</code></pre>

Build and run the container:

<pre><code>docker build -t playwright-tests ./playwright
docker run --network testnet playwright-tests</code></pre>

If the test fails, the agent will report a functional error even if k6 saw low latency. This double‑check eliminates the “green suite but broken business logic” scenario.

Running the Full Suite from Your Agent

Assume your AI agent can execute shell commands. A single script that the agent calls could look like:

<pre><code>#!/usr/bin/env bash
set -e

# 1️⃣ Warm up containers
docker compose up -d

# 2️⃣ Run k6 load test (timeout after 4 minutes)
docker run --rm --network testnet -v $(pwd)/k6:/scripts loadimpact/k6 run /scripts/checkout.js

# 3️⃣ Run Playwright sanity checks
docker build -t playwright-tests ./playwright
docker run --rm --network testnet playwright-tests

# 4️⃣ Tear down
docker compose down</code></pre>

The agent can parse the exit code of each step. If k6 returns a non‑zero code (threshold breach) or Playwright returns a non‑zero code (assertion failure), the agent reports “FAIL”. Only when both exit codes are zero does it answer “PASS”.

Analyzing the Output – What the Agent Should Surface

When the script finishes, k6 prints a summary like:

<pre><code>          /\      |‾‾| /‾‾/   /‾‾/
     /  \     |  |/  /   /  /
    / /\ \    |     (   (   )
   / ____ \   |  |\  \   \  \\
  /_/    \_\\  |__| \__\  \__\\

running (04m00.00s), 00/00 VUs, 12345 complete iterations
default ✓ [======================================] 100% 00/00 VUs

    ✓ status was 200

    checks.........................: 100.00% ✓ 12345 ✗ 0
    error rate....................: 0.00%   ✓ 0      ✗ 0
    api_latency...................: avg=312ms min=45ms max=1245ms p(95)=720ms
    server_cpu....................: avg=0.31
    thresholds....................: passed=2 failed=0
</code></pre>

The agent should extract the following facts before answering “PASS”:

If any of those conditions are false, the agent must explicitly list the violation – e.g., “FAIL: 95th‑percentile latency 1.2 s exceeds 800 ms”. This makes the response actionable without digging into logs.

Common Pitfalls and Quick Fixes

1️⃣ The Load Generator Can’t Reach the Target RPS

If k6 reports “0 VUs” or “max RPS not reached”, you are likely hitting a Docker networking bottleneck. Increase the host’s ulimit -n (open files) and allocate more CPU to the k6 container:

<pre><code>docker run --rm --network testnet \
  --cpus="2.0" \
  -v $(pwd)/k6:/scripts loadimpact/k6 run /scripts/checkout.js</code></pre>

2️⃣ Metrics Endpoint Overhead Skews Results

Calling /metrics on every iteration adds extra HTTP traffic. If you see a sudden jump in latency after adding the endpoint, switch to a “heartbeat” approach: call the endpoint once every 10 seconds from a separate k6 group that runs in parallel.

3️⃣ Playwright Fails Randomly on CI

Playwright depends on a stable DNS resolution for laravel and nextjs. In CI environments, the Docker network may be recreated between steps, causing flaky DNS. Use the container IP address (retrieved via docker inspect) or add an explicit extra_hosts entry in docker-compose.yml to guarantee name resolution.

4️⃣ Laravel Queues Stall Under Load

If you notice CPU staying low but response times climbing, the bottleneck may be a saturated queue (e.g., email or webhook jobs). Add a queue:work service to the compose file with --tries=3 and monitor its queue:failed count after the test.

Extending the Pattern to Edge Cases

Next.js can be deployed to Vercel or Cloudflare Workers, where the “container” abstraction disappears. The same principle works: replace the Docker nextjs service with a curl call to the real edge URL inside the k6 script, and keep the Playwright test pointed at that URL. The agent still receives a unified PASS/FAIL answer because the script logic hasn’t changed – only the target host.

What to Do Next

If you’ve followed the steps, your agent now validates both performance thresholds and functional correctness before saying “PASS”. The next logical step is to automate the script as a webhook that your CI system can trigger, but keep the decision point inside the agent – the CI only reports “agent says PASS”. This preserves the mental model that the agent is the single source of truth.

For deeper insight, consider exporting k6 metrics to InfluxDB and visualizing them alongside Laravel’s telescope data. That way the agent can answer questions like “Did the 95th‑percentile latency spike during the burst phase?” without you manually digging into logs.

With the rule “always correlate client latency with server health” baked into your test suite, you’ll stop getting false‑green results and start trusting the agent’s verdicts.