ehab-talaat / laravel-tornado
🌪️ HTTP stress testing for Laravel — live feed + full stats
Requires
- php: ^8.1
- guzzlehttp/guzzle: ^7.0
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0
- pestphp/pest: ^2.0
README
HTTP stress testing for Laravel — built to feel native.
Test any endpoint directly from Artisan. Live request feed, full stats, smart body generation, auth support, multi-step sequences, and beautiful HTML reports.
Table of Contents
Installation
composer require ehab-talaat/laravel-tornado --dev
Laravel auto-discovers the package. No config file needed.
Requirements: PHP 8.1+, Laravel 10/11/12, Guzzle 7+
Quick Start
# Stress test an endpoint in 30 seconds
php artisan tornado api/users --method=POST --requests=500 --concurrency=25
Commands
tornado — Stress test a single endpoint
php artisan tornado {url} [options]
Examples
# ── Basic GET ────────────────────────────────────────────────────────────── # 100 requests, 10 concurrent (defaults) php artisan tornado api/products # 1000 requests, 50 at a time php artisan tornado api/products --requests=1000 --concurrency=50 # ── POST with body ───────────────────────────────────────────────────────── php artisan tornado api/users \ --method=POST \ --requests=500 \ --concurrency=25 \ --body='{"name":"John","email":"john@test.com","password":"secret123"}' # ── With headers ─────────────────────────────────────────────────────────── php artisan tornado api/orders \ --method=POST \ --requests=200 \ --header="Authorization: Bearer YOUR_TOKEN" \ --header="Accept: application/json" \ --body='{"product_id":1,"qty":2}' # ── External URL ─────────────────────────────────────────────────────────── php artisan tornado https://api.example.com/v1/products --requests=300 # ── Summary only (hide live feed) ────────────────────────────────────────── php artisan tornado api/products --requests=1000 --no-live # ── Interactive mode (no arguments needed) ───────────────────────────────── php artisan tornado
tornado:sequence — Multi-step user flow
php artisan tornado:sequence {file?} [options]
Examples
# From a JSON file php artisan tornado:sequence sequence.json --flows=50 --concurrency=10 # Interactive — Tornado asks you step by step php artisan tornado:sequence # With HTML export for each step php artisan tornado:sequence sequence.json --flows=100 --export=html
Options Reference
tornado options
| Option | Default | Description |
|---|---|---|
url |
(required) | Relative /api/x or absolute https://… |
--method |
GET |
HTTP method: GET, POST, PUT, PATCH, DELETE |
--requests |
100 |
Total number of requests |
--concurrency |
10 |
Simultaneous requests |
--timeout |
10 |
Per-request timeout in seconds |
--header |
— | Repeatable: --header="Key: Value" |
--body |
— | Raw JSON string for POST/PUT |
--smart |
— | Auto-generate body from validation rules |
--auth |
— | Auth driver: sanctum or passport |
--export |
— | Export format: json or html |
--no-live |
— | Skip live feed, show summary only |
tornado:sequence options
| Option | Default | Description |
|---|---|---|
file |
(optional) | Path to JSON sequence file |
--flows |
10 |
How many full user flows to simulate |
--concurrency |
5 |
Concurrent flows at a time |
--export |
— | Export each step: json or html |
Features
🎯 Interactive Mode
Run php artisan tornado with no arguments and Tornado asks you everything:
🌪️ Laravel Tornado
Endpoint (e.g. api/users): api/users
Method:
[0] GET
> [1] POST
[2] PUT
[3] PATCH
[4] DELETE
Total requests [100]: 1000
Concurrency (simultaneous) [10]: 50
Auto-generate body with --smart? (yes/no) [no]: yes
Is this route authenticated? (yes/no) [no]: yes
🧠 Smart Mode — Auto-generate request body
Tornado reads your controller's validation rules and generates realistic fake data automatically using Faker. No need to write --body manually.
Supported validation styles:
// ✅ Array syntax $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email'], 'password' => ['required', 'string', 'min:8'], ]); // ✅ String syntax $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email', ]); // ✅ FormRequest class class StoreUserRequest extends FormRequest { public function rules(): array { return [ 'name' => ['required', 'string'], 'email' => ['required', 'email'], ]; } }
Usage:
php artisan tornado api/users --method=POST --requests=500 --smart
What Tornado generates per rule/field:
| Rule / Field name | Generated value |
|---|---|
email rule |
fake()->safeEmail() |
url rule |
fake()->url() |
integer / numeric |
fake()->numberBetween(min, max) |
boolean |
fake()->boolean() |
date |
fake()->date() |
uuid |
fake()->uuid() |
field contains name |
fake()->name() |
field contains password |
Password@123 format |
field contains phone |
fake()->phoneNumber() |
field contains address |
fake()->address() |
field contains city |
fake()->city() |
field contains price / amount |
fake()->randomFloat(2, 1, 999) |
field ends with _id |
fake()->numberBetween(1, 100) |
A fresh fake value is generated for every single request.
🔐 Auth Mode — Authenticated routes
Tornado logs in once, gets the token, and injects it into all requests automatically.
Interactive:
php artisan tornado api/orders --method=POST --requests=500 Is this route authenticated? yes Auth driver: > sanctum Login field: > email ← or username, phone, or type any custom field email: admin@test.com Password: ******** Logging in... ✓ Token obtained successfully.
Direct (skip prompts):
php artisan tornado api/orders --method=POST --requests=500 --auth=sanctum
Supported drivers:
| Driver | Login endpoints tried |
|---|---|
sanctum |
/api/login, /login, /api/auth/login, /api/sanctum/token |
passport |
/oauth/token, /api/oauth/token |
Supported response shapes (any of these work out of the box):
{ "token": "abc123" }
{ "access_token": "abc123" }
{ "data": { "token": "abc123" } }
{ "data": { "access_token": "abc123" } }
{ "authorisation": { "token": "abc123" } }
📊 Export Reports
Save results for sharing or comparing over time.
# JSON export php artisan tornado api/users --requests=1000 --export=json # HTML export (beautiful report with charts) php artisan tornado api/users --requests=1000 --export=html
Files are saved to storage/tornado/ with a timestamp:
storage/tornado/tornado_api-users_2024-01-15_14-30-22.html
storage/tornado/tornado_api-users_2024-01-15_14-30-22.json
JSON report structure:
{
"meta": {
"generated_at": "2024-01-15T14:30:22+00:00",
"package": "laravel-tornado"
},
"scenario": {
"url": "http://localhost/api/users",
"method": "POST",
"requests": 1000,
"concurrency": 50
},
"results": {
"total": 1000,
"success": 994,
"failed": 6,
"success_pct": 99.4,
"failed_pct": 0.6,
"duration_sec": 12.4,
"rps": 80.6,
"status_codes": { "200": 994, "500": 6 }
},
"response_times": {
"min": 31.0,
"avg": 142.3,
"p50": 128.0,
"p75": 210.5,
"p90": 310.2,
"p95": 380.0,
"p99": 610.4,
"max": 891.0
}
}
HTML report includes:
- Stat cards (total, RPS, success/fail, avg, P95)
- Latency distribution bar chart
- Status codes doughnut chart
- Full percentile table (min → max)
- Dark theme, self-contained single file
🔗 Sequence Mode — Multi-step flows
Simulate a real user flow: login → browse → create → logout. Each step passes data to the next via extract.
JSON file format
{
"steps": [
{
"name": "Login",
"method": "POST",
"url": "api/login",
"body": {
"email": "admin@test.com",
"password": "password"
},
"extract": {
"token": "data.token",
"user_id": "data.user.id"
}
},
{
"name": "Get Profile",
"method": "GET",
"url": "api/profile",
"headers": {
"Authorization": "Bearer {token}"
}
},
{
"name": "Create Order",
"method": "POST",
"url": "api/orders",
"headers": {
"Authorization": "Bearer {token}"
},
"smart": true,
"requests": 200,
"concurrency": 20
},
{
"name": "List My Orders",
"method": "GET",
"url": "api/users/{user_id}/orders",
"headers": {
"Authorization": "Bearer {token}"
}
},
{
"name": "Logout",
"method": "POST",
"url": "api/logout",
"headers": {
"Authorization": "Bearer {token}"
}
}
]
}
Run it:
php artisan tornado:sequence sequence.json --flows=100 --concurrency=10 --export=html
How extract works
Use dot-notation to pull any value from a JSON response:
"extract": { "token": "data.token", "user_id": "data.user.id", "company_id": "data.user.company.id" }
Then use {token}, {user_id}, {company_id} as placeholders in any later step's URL, headers, or body:
"url": "api/companies/{company_id}/users/{user_id}" "headers": { "Authorization": "Bearer {token}" } "body": { "owner_id": "{user_id}" }
Step-level options
Override load settings per step:
{
"name": "Heavy endpoint",
"url": "api/reports/generate",
"method": "POST",
"requests": 500,
"concurrency": 25
}
If omitted, the step inherits --flows and --concurrency from the command.
Interactive sequence
php artisan tornado:sequence
Define your steps (press Enter with empty URL to finish)
── Step 1 ──
URL (e.g. api/login): api/login
Step name [api/login]: Login
Method: POST
Auto-generate body with --smart? no
JSON body: {"email":"admin@test.com","password":"secret"}
Add headers? no
Extract values from response? yes
Extract (leave empty to stop): token=data.token
Extract (leave empty to stop): [Enter]
── Step 2 ──
URL: api/orders
...
Reading the Output
Live feed
[ 1/1000] [█░░░░░░░░░░░░░░░░░░░] 200 43.2ms
[ 2/1000] [█░░░░░░░░░░░░░░░░░░░] 200 38.7ms
[ 3/1000] [█░░░░░░░░░░░░░░░░░░░] 500 210.1ms → Internal Server Error
Final summary
───────────────────────────────────────────────────────
🌪️ Tornado Report
───────────────────────────────────────────────────────
Total : 1000 requests
Duration : 12.4s
Throughput : 80.6 req/sec
✅ Success : 994 (99.4%)
❌ Failed : 6 (0.6%)
Status codes:
200 ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 994 (99.4%)
500 ■ 6 (0.6%)
Response times (successful requests):
Min : 31.0ms
Avg : 142.3ms
P50 : 128.0ms
P75 : 210.5ms
P90 : 310.2ms
P95 : 380.0ms ← 95% of requests finished within this
P99 : 610.4ms ← only 1% were slower than this
Max : 891.0ms
Latency dist : ▂▄▇█▅▃▂▁▁▁
31ms 891ms
What the metrics mean
| Metric | What it tells you |
|---|---|
| RPS | Requests per second your server handled |
| P50 | Half your users are faster than this |
| P95 | 95% of users are faster than this — your real-world experience |
| P99 | Your worst-case users |
| Failed % | High = DB overload, rate limiting, or crashes |
| 500 errors | Check storage/logs/laravel.log |
Performance benchmarks
| P95 response | Rating |
|---|---|
| < 100ms | 🟢 Excellent |
| 100–300ms | 🟡 Good |
| 300–800ms | 🟠 Needs work |
| > 800ms | 🔴 Critical |
License
MIT — Ehab Talaat