Skip to content

Load testing in Laravel Sail

Use this overlay when you need to plot k6 HTTP latency (especially p95/p99) against MySQL InnoDB row-lock depth in the same Grafana window while load-testing a Laravel Sail app.

It is not started by Sail, not used in production, and not a general application APM stack. k6 itself is not part of the overlay — you run k6 on the host (or from a sibling load-test repo) and push metrics into the Prometheus this overlay starts.

┌─────────────────────────┐ scrape every 5s ┌──────────────────┐
│ mysqld-exporter :9104 │◄─────────────────────────────────│ │
│ (prom/mysqld-exporter) │ │ Prometheus │
└───────────┬─────────────┘ │ :9090 │
│ TCP 3306 as user `exporter` │ │
▼ │ TSDB volume │
┌─────────────────────────┐ remote write /api/v1/write │ prometheus-data │
│ Sail MySQL │ │ │
│ hostname: mysql │ └────────▲─────────┘
│ network: your-project_sail │
└─────────────────────────┘ PromQL queries │
┌────────┴─────────┐
┌─────────────────────────┐ POST http://localhost:9090/ │ Grafana :3000 │
│ k6 on the host │ api/v1/write │ anonymous Admin │
│ │─────────────────────────────────►│ grafana-data │
└─────────────────────────┘ └──────────────────┘

Two ingestion paths land in the same Prometheus:

PathDirectionUse this
Scrape mysqld-exporter:9104Prometheus pullsAlways, while the overlay is up
Remote write POST /api/v1/writek6 pushesPreferred way to connect k6
Scrape host.docker.internal:5656Prometheus pullsOptional / unused unless you run an xk6 Prometheus HTTP exporter

Grafana is a viewer only. It does not scrape anything itself.

Four files make up the stack. Sail’s compose.yaml is unchanged. The SQL is not mounted into MySQL’s /docker-entrypoint-initdb.d, so you must apply it yourself.

  • docker-compose.monitoring.yml Overlay compose file — run with docker compose, not Sail
  • Directorydocker/
    • Directorymysqld-exporter/
      • .my.cnf Exporter MySQL client credentials
    • Directorymysql/
      • create-exporter-user.sql One-shot grants for the exporter user
    • Directoryprometheus/
      • prometheus.yml Scrape config

Overlay compose file. Run it with docker compose -f, not via Sail.

It starts three services on Sail’s existing Docker network, publishes Grafana / Prometheus / exporter ports to the host, and keeps TSDB + Grafana state in named volumes.

Replace your-project_sail with your Compose network name. Docker names networks after the repo folder plus _sail (for example a folder named shop becomes shop_sail). If you set COMPOSE_PROJECT_NAME, use that instead of the folder name.

docker-compose.monitoring.yml
services:
mysqld-exporter:
image: prom/mysqld-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "exporter:password@(mysql:3306)/"
command:
- '--config.my-cnf=/etc/.my.cnf'
- '--collect.info_schema.innodb_metrics'
- '--collect.info_schema.processlist'
- '--collect.perf_schema.eventswaits'
- '--collect.perf_schema.tablelocks'
- '--collect.global_status'
- '--collect.global_variables'
volumes:
- ./docker/mysqld-exporter/.my.cnf:/etc/.my.cnf
ports:
- '9104:9104'
networks:
- sail
prometheus:
image: prom/prometheus
restart: unless-stopped
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-remote-write-receiver'
volumes:
- ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
ports:
- '9090:9090'
networks:
- sail
grafana:
image: grafana/grafana
restart: unless-stopped
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- grafana-data:/var/lib/grafana
ports:
- '3000:3000'
networks:
- sail
networks:
sail:
external: true
name: your-project_sail
volumes:
prometheus-data:
grafana-data:

Notes on this file:

  • networks.sail.external: true — the overlay does not create the Sail network. vendor/bin/sail up must already have created it.
  • --web.enable-remote-write-receiver — this is what makes k6’s POST /api/v1/write work. A stock Prometheus without that flag returns 404.
  • DATA_SOURCE_NAME — leftover from the pre-v0.15 exporter config style. Current prom/mysqld-exporter uses --config.my-cnf; the env var can be ignored.
  • Anonymous Grafana Admin — anyone who can reach localhost:3000 is Admin. Fine for a laptop load-test session; unsafe anywhere else.
  • restart: unless-stopped — overlay containers come back after a Docker restart until you down them.

MySQL client credentials for the exporter. Mounted into the exporter container at /etc/.my.cnf.

docker/mysqld-exporter/.my.cnf
[client]
user=exporter
password=password
host=mysql
port=3306

host=mysql is the Sail service name, not localhost. It only resolves because the exporter is attached to the Sail network.

One-shot SQL to create the MySQL user the exporter logs in as. Matches the mysqld-exporter required grants.

docker/mysql/create-exporter-user.sql
CREATE USER IF NOT EXISTS 'exporter'@'%' IDENTIFIED BY 'password' WITH MAX_USER_CONNECTIONS 3;
GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'%';
FLUSH PRIVILEGES;
PieceMeaning
'exporter'@'%'Any client host. Needed because the exporter container is not localhost from MySQL’s point of view
passwordHardcoded local secret. Same value as .my.cnf. Do not reuse in production
MAX_USER_CONNECTIONS 3Caps the exporter so scrapes cannot exhaust the server under load
PROCESSRequired to read the processlist
REPLICATION CLIENTRequired by the default slave_status collector even on a standalone server
SELECT ON *.*Required for SHOW GLOBAL STATUS, innodb_metrics, performance_schema

Re-running is safe (IF NOT EXISTS). Init scripts in /docker-entrypoint-initdb.d only run when the sail-mysql volume is first created, and this file is not mounted there, so an existing Sail volume will never pick it up automatically.

Prometheus scrape config. Mounted into the Prometheus container at /etc/prometheus/prometheus.yml.

docker/prometheus/prometheus.yml
global:
scrape_interval: 5s
scrape_configs:
- job_name: 'mysqld'
static_configs:
- targets: ['mysqld-exporter:9104']
- job_name: 'k6'
static_configs:
- targets: ['host.docker.internal:5656']

scrape_interval: 5s is much faster than Prometheus’s default of 1m. Lock spikes last hundreds of milliseconds to a few seconds; a 1m scrape would miss them.

mysqld-exporter:9104 is Docker DNS on the Sail network. Do not change this to localhost:9104 inside Prometheus — that would mean Prometheus’s own localhost.

The k6 scrape job is leftover config for the optional pull path. You do not need it for the recommended remote-write path.

These flags are extra collectors on top of the exporter defaults (global_status, global_variables, and slave_status are already on). They were chosen for hot-row contention during load tests, not a generic MySQL dashboard.

FlagWhat it readsWhy it is here
--collect.global_statusSHOW GLOBAL STATUSInnodb_row_lock_current_waits, Innodb_row_lock_time, Innodb_row_lock_waits
--collect.global_variablesSHOW GLOBAL VARIABLESServer config snapshot (innodb lock wait timeout, max connections, …)
--collect.info_schema.innodb_metricsinformation_schema.innodb_metricsDeadlocks (mysql_info_schema_innodb_metrics_lock_lock_deadlocks_total)
--collect.info_schema.processlistinformation_schema.processlistThread-state counts (sessions sitting in lock wait)
--collect.perf_schema.eventswaitsevents_waits_summary_global_by_event_nameWait-event time, including lock waits
--collect.perf_schema.tablelockstable_lock_waits_summary_by_tableTable-level lock waits

Series names Prometheus stores:

  • mysql_global_status_innodb_row_lock_current_waits
  • mysql_global_status_innodb_row_lock_time
  • mysql_global_status_innodb_row_lock_waits
  • mysql_info_schema_innodb_metrics_lock_lock_deadlocks_total

All commands are from the Laravel repo root. PHP / Artisan / Sail go through vendor/bin/sail. The overlay is plain docker compose because it is not part of Sail.

  • Docker Desktop running
  • The Laravel repo cloned, Sail installed (vendor/bin/sail)
  • Ports 3000, 9090, and 9104 free on the host (Grafana / Prometheus / exporter). Sail already uses 80, 3306, 6379, 8025, 8080, 5173
  1. Start Sail first. The overlay joins Sail’s network and talks to the mysql service. If Sail is down, the overlay fails with “network your-project_sail not found” or the exporter cannot resolve mysql.
Terminal window
vendor/bin/sail up -d

Confirm the network exists (replace your-project with the repo directory name):

Terminal window
docker network ls | grep _sail

Confirm MySQL is healthy:

Terminal window
vendor/bin/sail ps
  1. Create the exporter user (once per MySQL volume).
Terminal window
vendor/bin/sail mysql < docker/mysql/create-exporter-user.sql

If sail mysql is not available, exec in as root and paste the three statements from docker/mysql/create-exporter-user.sql.

Re-run is safe. You only need to redo this after vendor/bin/sail down -v (which destroys sail-mysql).

  1. Start the monitoring overlay. Not via Sail:
Terminal window
docker compose -f docker-compose.monitoring.yml up -d

First pull can take a minute (prom/mysqld-exporter, prom/prometheus, grafana/grafana).

Check the three containers are up:

Terminal window
docker compose -f docker-compose.monitoring.yml ps

You should see mysqld-exporter, prometheus, and grafana with state running.

  1. Check health.
CheckExpect
http://localhost:9090/-/healthyPrometheus up
http://localhost:9090/targetsmysqld UP; k6 DOWN until something listens on host port 5656 (expected if you use remote write)
http://localhost:9104/metricsPrometheus text with mysql_global_status_*
http://localhost:3000/api/healthGrafana up
Prometheus graph: mysql_global_status_innodb_row_lock_current_waitsA gauge, usually 0 at idle

If mysqld is DOWN: Sail not on the expected network, exporter user missing, or password mismatch between the SQL and .my.cnf.

  1. Add the Grafana datasource (once per grafana-data volume). The overlay does not provision a datasource or dashboard as files. They live in the grafana-data volume after you create them.

    1. Open http://localhost:3000. There is no login (anonymous Admin).
    2. Connections → Data sources → Add data source → Prometheus.
    3. Set URL to http://prometheus:9090 — the Docker service name, not localhost. Grafana queries from inside the Sail network; localhost inside that container is Grafana itself.
    4. Save & test. It should succeed.
    5. Build or import panels (see Grafana panels).

Port 3000 will clash if another Grafana (or anything else) is already bound there.

Terminal window
docker compose -f docker-compose.monitoring.yml down # keep TSDB + Grafana volumes
docker compose -f docker-compose.monitoring.yml down -v # wipe metrics and dashboards

Leaving the overlay up is fine. Tear it down if you need ports 3000 / 9090 / 9104.

Sail is independent:

Terminal window
vendor/bin/sail stop # app + MySQL + Redis still stopped; overlay can keep running but MySQL scrapes will fail
vendor/bin/sail down # does not remove the overlay

k6 runs on the host, not in this compose file. Prometheus is published at localhost:9090, which is the address k6 should use.

There are two ways this stack can ingest k6 metrics. Use remote write unless you have a reason not to.

k6’s built-in output -o experimental-prometheus-rw pushes samples to Prometheus. That is why Prometheus is started with --web.enable-remote-write-receiver.

Official docs: k6 Prometheus remote write.

  1. Install k6. You do not need Grafana Cloud, xk6, or a custom k6 binary for remote write.
Terminal window
brew install k6
  1. Point k6 at this Prometheus. Default remote-write URL is already http://localhost:9090/api/v1/write, which matches the published port. Set it explicitly anyway, and set trend stats — without K6_PROMETHEUS_RW_TREND_STATS, k6 only sends p(99).
Terminal window
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
K6_PROMETHEUS_RW_TREND_STATS="min,avg,med,max,p(90),p(95),p(99)" \
k6 run -o experimental-prometheus-rw script.js

k6 must be able to reach localhost:9090 (overlay up, port published). It does not need to be on the Docker network.

If Prometheus was started without --web.enable-remote-write-receiver, this POST returns 404.

  1. Hit the Sail app. Anything that produces k6 HTTP metrics will show up. Example against local Sail (APP_PORT defaults to 80):
script.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10,
duration: '30s',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<2000'],
},
};
export default function () {
const res = http.get('http://localhost/');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}

Replace the URL with whatever flow you care about (checkout, login, a hot write path). Tag a run so Grafana can overlay experiments:

Terminal window
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
K6_PROMETHEUS_RW_TREND_STATS="min,avg,med,max,p(90),p(95),p(99)" \
k6 run -o experimental-prometheus-rw --tag testid=load-$(date +%Y%m%d-%H%M) script.js

testid becomes a Prometheus label on the k6_* series.

  1. Confirm metrics arrived. In http://localhost:9090/graph:
k6_http_req_duration_p99
k6_http_req_duration_p95
k6_http_req_failed_rate
k6_vus

Useful series after a run (seconds for duration stats):

k6 metricPrometheus series (with the TREND_STATS above)
http_req_durationk6_http_req_duration_p99, _p95, _p90, _med, _avg, _min, _max
http_req_failedk6_http_req_failed_rate
checksk6_checks_rate
vusk6_vus

If those queries are empty: overlay not up, wrong K6_PROMETHEUS_RW_SERVER_URL, or you omitted -o experimental-prometheus-rw.

The k6 target on /targets staying DOWN is normal for this path. Remote-write samples do not go through that scrape job.

Optional npm wrapper if you keep scripts in another repo:

{
"scripts": {
"prom": "K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write K6_PROMETHEUS_RW_TREND_STATS=min,avg,med,max,p(90),p(95),p(99) k6 run -o experimental-prometheus-rw script.js"
}
}

That is all this stack requires from k6: host process, remote-write URL, trend stats, overlay running.

Path B (optional): Prometheus scrapes k6 on port 5656

Section titled “Path B (optional): Prometheus scrapes k6 on port 5656”

prometheus.yml already has:

- job_name: 'k6'
static_configs:
- targets: ['host.docker.internal:5656']

That is the listen address used by the older xk6-prometheus HTTP exporter: Prometheus pulls /metrics from the host.

Built-in k6 does not expose port 5656. Until something listens there, the k6 target is DOWN. That is expected.

Only use this path if you build a custom k6 with xk6-prometheus (or another exporter) and bind it to 0.0.0.0:5656. host.docker.internal is provided by Docker Desktop on macOS. The Prometheus service does not set extra_hosts (Sail’s laravel.test does). On Linux you may need:

# under the prometheus service in docker-compose.monitoring.yml
extra_hosts:
- 'host.docker.internal:host-gateway'

Prefer Path A. Path B is leftover scrape config, not required to connect k6.

Native histograms (optional, not enabled here)

Section titled “Native histograms (optional, not enabled here)”

k6 can send trends as Prometheus native histograms (K6_PROMETHEUS_RW_TREND_AS_NATIVE_HISTOGRAM=true). That needs Prometheus ≥ 2.40 with --enable-feature=native-histograms. This overlay does not set that flag. Stick to K6_PROMETHEUS_RW_TREND_STATS gauges unless you change the Prometheus command.

Watch these together during a load test. Averages and p50 alone hide the lock queue.

SignalPromQLHealthyContention
Request p50 vs p95/p99k6_http_req_duration_med, k6_http_req_duration_p95, k6_http_req_duration_p99Lines close togetherTail peels away from p50 while p50 still looks fine
Current row-lock waitersmysql_global_status_innodb_row_lock_current_waits~0Tracks concurrent waiters on hot rows
Lock wait raterate(mysql_global_status_innodb_row_lock_waits[1m])QuietRises with herd windows
Deadlocksmysql_info_schema_innodb_metrics_lock_lock_deadlocks_totalFlatSteps up if deadlocks start
Failed HTTPk6_http_req_failed_rate~0Climbs if the flow starts 5xx / timeouts
Virtual usersk6_vusMatches your scriptConfirms the herd actually ramped

k6 duration series are in seconds. Multiply by 1000 in Grafana if you want milliseconds.

PortServiceWho uses it
3000Grafana UIYou (browser)
9090Prometheus UI and remote writeYou (browser); k6 (/api/v1/write)
9104mysqld-exporter /metricsPrometheus on the Docker network; host port is only for debugging
  • Not production monitoring. Credentials, anonymous Grafana Admin, and host-published ports are local conveniences.
  • Not Laravel / Redis / PHP-FPM metrics. Only MySQL (scraped) and whatever you push from k6.
  • Not started by vendor/bin/sail. Forgetting the overlay is a common reason Grafana is empty.
  • Not auto-creating the exporter user. A fresh Sail volume still needs the SQL.
  • Not provisioning Grafana dashboards in git. Those live in grafana-data.