arity Configuration
Load testing against a development or cached environment produces misleading metrics. The test target must mirror production resource limits.
PHP-FPM Pool Configuration:
; /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 64
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 16
pm.max_requests = 1000
request_terminate_timeout = 30s
pm.max_children defines the hard concurrency ceiling. pm.max_requests forces worker recycling to mitigate memory leaks common in long-running PHP processes. The test environment must replicate these values exactly.
Infrastructure Checklist:
- Match PHP version and FPM SAPI
- Replicate database engine version and approximate row counts
- Enable OPcache with identical
opcache.memory_consumption
- Bypass CDN to test origin server behavior
- Introduce network latency (e.g.,
tc or proxy) if testing locally
Step 2: K6 Script Architecture (Modern API)
K6 uses JavaScript with a modern scenarios API for precise traffic modeling. The following script demonstrates a parameterized e-commerce flow with threshold enforcement.
// load-test-checkout.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
const TARGET = __ENV.TEST_URL || 'https://staging-api.internal';
const TIMEOUT = 2000;
// Load test data once, distribute across VUs efficiently
const catalog = new SharedArray('product_catalog', () => {
return JSON.parse(open('./fixtures/products.json'));
});
const credentials = new SharedArray('user_creds', () => {
return JSON.parse(open('./fixtures/accounts.json'));
});
export const options = {
scenarios: {
checkout_flow: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '3m', target: 25 },
{ duration: '5m', target: 25 },
{ duration: '2m', target: 60 },
{ duration: '4m', target: 60 },
{ duration: '2m', target: 0 },
],
gracefulRampDown: '30s',
},
},
thresholds: {
'http_req_duration': ['p(95)<450', 'p(99)<900'],
'http_req_failed': ['rate<0.015'],
'checkout_success': ['p(95)<600'],
},
};
export default function () {
const userIdx = __VU % credentials.length;
const account = credentials[userIdx];
const item = catalog[Math.floor(Math.random() * catalog.length)];
group('Authentication', () => {
const csrf = http.get(`${TARGET}/csrf-token`, { timeout: TIMEOUT });
const token = csrf.json('token');
const login = http.post(
`${TARGET}/auth/login`,
JSON.stringify({ email: account.email, password: account.secret }),
{
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
'Accept': 'application/json',
},
timeout: TIMEOUT,
}
);
check(login, { 'auth_valid': (r) => r.status === 200 && r.json('session_id') !== undefined });
const session = login.json('session_id');
sleep(1.2);
group('Checkout', () => {
const cart = http.post(
`${TARGET}/cart/add`,
JSON.stringify({ sku: item.sku, qty: 1 }),
{ headers: { 'Authorization': `Bearer ${session}` }, timeout: TIMEOUT }
);
const order = http.post(
`${TARGET}/orders/submit`,
JSON.stringify({ cart_id: cart.json('id'), payment_method: 'test' }),
{ headers: { 'Authorization': `Bearer ${session}` }, timeout: TIMEOUT }
);
check(order, { 'order_created': (r) => r.status === 201 });
});
});
sleep(Math.random() * 2 + 1.5);
}
Architecture Rationale:
scenarios.executor: 'ramping-vus' provides explicit control over ramp-up, plateau, and ramp-down phases.
SharedArray prevents memory duplication across VUs, critical when testing with thousands of concurrent users.
- Thresholds are defined as code, enabling CI/CD integration. The
checkout_success tag allows endpoint-specific SLOs.
- Randomized think time (
sleep) prevents synchronized request bursts that don't reflect real user behavior.
Step 3: Artillery YAML Configuration
Artillery excels at rapid scenario definition using declarative YAML. It integrates well with Socket.io and WebSocket-heavy PHP applications.
# artillery-load.yaml
config:
target: "{{ $ENV.TEST_URL }}"
phases:
- duration: 180
arrivalRate: 10
name: "Warm-up"
- duration: 300
arrivalRate: 35
name: "Steady-state"
- duration: 120
arrivalRate: 70
name: "Peak-load"
plugins:
ensure: {}
metrics-by-endpoint: {}
ensure:
thresholds:
- http.response_time.p95: 400
- http.response_time.p99: 850
- http.codes["5xx"]: 0
scenarios:
- name: "API Workflow"
flow:
- get:
url: "/health"
capture:
- json: "$.nonce"
as: "request_nonce"
- post:
url: "/api/v2/data/sync"
json:
client_id: "load-test-{{ $randomString(8) }}"
payload: "{{ $randomString(32) }}"
nonce: "{{ request_nonce }}"
headers:
Content-Type: "application/json"
X-Client-Version: "2.1.0"
capture:
- json: "$.status"
as: "sync_status"
- think: 2
Architecture Rationale:
arrivalRate controls throughput directly, useful for testing PHP queue workers or background job processors.
plugins.ensure enforces SLOs without custom scripting.
- Dynamic data generation (
$randomString) prevents cache hit inflation and tests database write paths accurately.
Step 4: CI/CD Integration
Both tools exit with non-zero codes when thresholds are breached. Integrate them into your pipeline:
# GitHub Actions example
- name: Run K6 Load Test
run: k6 run --summary-export=results.json load-test-checkout.js
env:
TEST_URL: ${{ secrets.STAGING_ENDPOINT }}
- name: Validate SLOs
run: |
if [ $? -ne 0 ]; then
echo "Performance regression detected. Build halted."
exit 1
fi
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Testing against CDN or cached layers | CDNs absorb traffic, masking origin server limits. OPcache or Redis hits skew latency metrics. | Bypass CDN via custom headers or test the origin IP directly. Disable application-level caching during tests. |
Ignoring pm.max_requests behavior | PHP workers recycle after N requests. If pm.max_requests is too low, constant process spawning increases CPU overhead and latency spikes. | Set pm.max_requests to 500β1000. Monitor php-fpm.log for worker recycling frequency during tests. |
| Using fixed think time | Synchronized requests create artificial thundering herd patterns. PHP-FPM sees burst concurrency that doesn't match production. | Use randomized sleep intervals (Math.random() * range + base). Model actual user journey durations. |
| Neglecting ramp-down recovery | Load dropping doesn't guarantee clean resource release. Database connections, file locks, or Redis keys may remain held. | Include a ramp-down phase. Monitor connection pool metrics and lock tables post-test. |
| Optimizing for average latency | Averages hide tail degradation. PHP session file locking or slow queries affect a subset of requests, inflating p99. | Enforce p95/p99 thresholds. Use distributed tracing to identify slow paths during load. |
| Hardcoding test credentials | Reusing the same user/session causes cache collisions and session lock contention, producing false positives. | Parameterize data using SharedArray or dynamic generation. Ensure each VU uses unique credentials. |
| Skipping database connection pool tuning | PHP-FPM workers open DB connections on demand. If the pool isn't sized for pm.max_children, connections queue or fail. | Align DB max connections with pm.max_children * 1.2. Use connection pooling (PgBouncer, ProxySQL) for MySQL/PostgreSQL. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Rapid smoke test before merge | Artillery YAML | Declarative syntax, fast iteration, low learning curve | Minimal (local execution) |
| Complex auth flows + conditional logic | K6 JavaScript | Programmable scenarios, SharedArray, granular thresholds | Low (open-source) |
| WebSocket/Socket.io heavy PHP app | Artillery | Native Socket.io support, arrival rate control | Low-Medium |
| CI/CD performance gate | K6 | Threshold-as-code, JSON summary export, native exit codes | Low |
| Enterprise SLO enforcement | K6 + Grafana Cloud / Artillery Cloud | Centralized dashboards, historical trend analysis, alerting | Medium-High (SaaS pricing) |
Configuration Template
PHP-FPM Production-Ready Pool:
[www]
user = www-data
group = www-data
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 64
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 16
pm.max_requests = 1000
request_terminate_timeout = 30s
slowlog = /var/log/php-fpm/www-slow.log
K6 Threshold Configuration (SLO Template):
export const options = {
thresholds: {
'http_req_duration': ['p(95)<400', 'p(99)<800'],
'http_req_failed': ['rate<0.01'],
'http_req_waiting': ['p(95)<300'],
'http_req_duration{type:api}': ['p(95)<350'],
'http_req_duration{type:static}': ['p(95)<50'],
},
};
Quick Start Guide
- Prepare Staging Environment: Clone production PHP-FPM config, match database version, enable OPcache, and bypass CDN.
- Install Tooling:
npm install -g artillery or brew install k6. Verify with k6 version or artillery --version.
- Create Test Script: Use the K6 or Artillery template above. Replace endpoints with your staging URL. Add parameterized fixtures.
- Execute Baseline Run:
k6 run script.js or artillery run config.yaml. Observe console output for threshold breaches.
- Integrate CI Gate: Add the execution command to your pipeline. Configure the job to fail on non-zero exit codes. Archive JSON summaries for trend analysis.
Load testing is not a one-time validation step. It is a continuous engineering practice that quantifies PHP application resilience, enforces service level objectives, and prevents production incidents before they impact users. By treating performance thresholds as deployment gates and mirroring production constraints, teams transform PHP backend scaling from reactive guesswork into predictable, data-driven engineering.