Realistic API Performance Testing for Laravel/Next.js with k6 and Playwright
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:
- k6 for HTTP load generation.
- Playwright for end‑to‑end (E2E) API sanity checks.
- Docker to keep the environment identical for every run.
- Laravel (PHP 8.2) and Next.js (React 18) as the services under test.
Setting Up a Self‑Contained Docker Lab
First, create a docker-compose.yml that spins up three containers:
- laravel – runs
php artisan serveon port 8000. - nextjs – runs
next devon port 3000. - k6 – the load generator.
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:
- A burst of 20 requests per second (RPS) for the first 30 seconds – think of a flash sale.
- A steady “background” of 5 RPS for the next 2 minutes – normal browsing.
- A cool‑down period where we stop sending traffic but keep measuring latency.
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:
Dockerfile– based onmcr.microsoft.com/playwright.tests/checkout.spec.js– a minimal sanity test.
<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”:
- All thresholds met (latency < 800 ms, error rate < 1 %).
- Server CPU stayed below 0.5 (or any threshold you define).
- No Playwright assertion failed.
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.