Fast, simple and extensible HTTP smoke-testing toolkit for PHP 8.4+.
A fluent DSL for declaring HTTP smoke tests, parallel execution, capture chains
between requests, cookie-shared session flows, pluggable variable sources
(.env, JSON, OS env, your own), pluggable reporters (console, JSON, Markdown, HTML,
GitHub Actions step summary, your own), and a small DI container so any service
can be swapped without forking the package.
composer require --dev stromcom/http-smokevendor/bin/http-smoke dev- PHP 8.4+ —
readonlyclasses, enums, strict types, no legacy cruft. - Fluent DSL — declarative test definitions in plain PHP.
- Parallel execution —
curl_multi-based, concurrency configurable. - Session chains — share cookies across a sequence of requests, run sequentially.
- Capture variables —
captureJsonPath('hash', 'data.thread.hash')then{@hash}in subsequent URLs / bodies / headers. - Variable substitution —
{ENV_VAR}placeholders resolved from.env,smokeHttp.json, OS env, CLI overrides — or any customVariableSourceInterface. - Pluggable reporters — console, JSON (canonical artefact), Markdown, standalone
HTML (browsable, with filters and chain context), GitHub step summary; add your
own by implementing
ReporterInterface. - Pluggable HTTP client — default is curl_multi; implement
HttpClientInterfaceto swap in a mock or alternative backend. - Retry — per-test or per-group retry on any failure (useful for eventually-consistent endpoints), independent 5xx retry budget.
- Circuit breaker — per-group
maxFailuresskips remaining tests once a group is clearly broken. - PHPStan level max + strict rules, no docblock noise.
composer require --dev stromcom/http-smoketests/SmokeHttp/api-01-basic.php:
<?php
declare(strict_types=1);
use Stromcom\HttpSmoke\Definition\Suite;
return static function (Suite $suite): void {
$suite->group('api.public', maxFailures: 3)
->baseUrl('{API_BASE_URL}')
->get('/ping')
->expectStatus(200)
->expectJsonPath('status', 'ok')
->get('/version')
->expectStatus(200)
->expectJsonHasKeys(['version', 'commit']);
};Either via .env.dev:
API_BASE_URL=http://localhost:8080/api…or tests/smokeHttp.json:
{
"dev": { "API_BASE_URL": "http://localhost:8080/api" },
"prod": { "API_BASE_URL": "https://example.com/api" }
}vendor/bin/http-smoke dev
vendor/bin/http-smoke prod --concurrency=20 --output-json=report.jsonhttp-smoke <environment> [options]
--concurrency=N Max parallel requests (default: 10)
--config-dir=DIR Test definitions directory (default: tests/SmokeHttp)
--config=FILE Path to smoke.config.php (default: ./smoke.config.php)
--smoke-json=FILE Path to smokeHttp.json (default: ./tests/smokeHttp.json)
--env-file=FILE Path to .env.<env> file
--base-url=URL Override APP_BASE_URL variable
--var=KEY=VALUE Set/override an arbitrary variable (repeatable)
--filter=PATTERN Only run files matching *PATTERN*
--group=NAME Only run group(s); supports wildcards (api.*)
--output=FILE Write Markdown report
--output-json=FILE Write canonical JSON report
--output-html=FILE Write standalone HTML report
--no-payloads Omit request/response payloads
--payload-limit=BYTES Truncate each payload body (default: 16384)
--no-console Suppress console output
--no-github-summary Skip GITHUB_STEP_SUMMARY
--verbose, -v Show full request/response detail
--insecure, -k Skip TLS verification
--help, -h
Options take their value either with = or as the next argument:
vendor/bin/http-smoke prod --output-html=report.html
vendor/bin/http-smoke prod --output-html report.htmlUnknown options, missing values, non-numeric numbers and stray positional
arguments are hard errors (exit code 3) — the runner never silently ignores a
flag it does not know, so a typo like --output-htlm= cannot quietly skip your
report.
Exit codes: 0 success · 1 tests failed · 2 config error · 3 usage error.
$suite->header('Authorization', '{API_TOKEN}'); // suite-level default header
$suite->asJson(); // suite-level: send all bodies as JSON
$suite->group('api.users', maxFailures: 3)
->baseUrl('{API_BASE_URL}')
->header('X-Tenant', 'smoke')
->defaultTimeout(5)
->defaultRetryOnFailure(10, 50) // up to 10 retries, 50 ms apart
// ->defaultRetries(10, 50) // shorter alias for the above
->defaultAsJson()
// Sessions: shared cookie jar, sequential execution, fail-fast
->session('user-lifecycle')
->post('/users/', ['email' => 'x@y.z'])
->expectStatus(201)
->captureJsonPath('userHash', 'data.hash')
->get('/users/{@userHash}/')
->expectStatus(200)
->delete('/users/{@userHash}/')
->expectStatus(204)
->endSession()
// Independent (parallel) requests
->get('/users/')
->expectStatus(200)
->expectJson()
->expectJsonHasKeys(['data', 'meta.count'])
->expectJsonPath('status', 'success')
->put('{@externalUploadUrl}', file_get_contents('photo.jpg'))
->noGroupHeaders() // skip group Authorization header
->asJson(false) // raw body
->expectStatus(200)
->get('/dashboard')
->expectStatus(200)
->expectHtmlElement('h1', 'Dashboard') // <h1>Dashboard</h1>
->expectHtmlElement('a', null, 'href', '/logout')
->expectHtmlElement('meta', null, 'name', 'viewport')
->get('/health')
->expectHeaderContains('Cache-Control', 'no-store')
->expect(fn (Stromcom\HttpSmoke\Http\Response $r) =>
json_decode($r->body, true)['queue'] > 0 ? 'queue should be empty' : null
);| Method | Purpose |
|---|---|
expectStatus(int) |
exact status code |
expectStatusOneOf(int, ...) |
one of several status codes |
expectRedirect(string) |
3xx with Location header path-matching the URL |
expectContains(string) / expectNotContains(string) |
body substring |
expectJson() |
body must parse as JSON |
expectJsonHasKeys(array) |
dot-notation paths must exist |
expectJsonPath(string, mixed) |
dot-notation path equals value |
expectHtmlElement(tag, text?, attribute?, attributeValue?) |
HTML body must contain a matching <tag> (optionally with given attribute / text) |
expectHeaderContains(string, string) |
header value contains substring |
expect(Closure) |
custom callback returning null on success or a failure message |
Use captureJsonPath('name', 'data.x.y') (or captureHeader('name', 'X-Foo')) on
any request, then reference {@name} in any later request's URL, body, or
header value.
The package merges configuration from several layers (later wins):
- Defaults
smoke.config.php(PHP — for registering custom services / closures)smokeHttp.json(per-environment static values).env.<environment>- OS environment variables (
getenv()) - CLI options (
--base-url,--var=KEY=VALUE, …)
{
"dev": { "API_BASE_URL": "http://localhost:8080/api" },
"staging": { "API_BASE_URL": "https://staging.example.com/api" },
"prod": { "API_BASE_URL": "https://example.com/api" }
}<?php
declare(strict_types=1);
use Stromcom\HttpSmoke\Config\SmokeConfig;
use Stromcom\HttpSmoke\Container\Container;
use Stromcom\HttpSmoke\Variable\Source\ArraySource;
return static function (SmokeConfig $config): void {
$config->configDir = __DIR__ . '/tests/SmokeHttp';
$config->concurrency = 10;
$config->jsonOutputPath = __DIR__ . '/build/smoke.json';
$config->markdownOutputPath = __DIR__ . '/build/smoke.md';
$config->htmlOutputPath = __DIR__ . '/build/smoke.html';
// Plug in additional variable sources
$config->extraVariableSources[] = new ArraySource([
'CUSTOM_KEY' => 'value',
]);
// Override services in the DI container
$config->configureContainer = function (Container $container): void {
// $container->set(HttpClientInterface::class, fn() => new MyClient());
};
};Implement Stromcom\HttpSmoke\Variable\VariableSourceInterface:
final class AwsSecretsSource implements VariableSourceInterface
{
public function get(string $name): ?string { /* ... */ }
public function all(): array { /* ... */ }
}
// In smoke.config.php:
$config->extraVariableSources[] = new AwsSecretsSource(...);Implement Stromcom\HttpSmoke\Reporting\ReporterInterface (onStart,
onResult, onEnd) and add it via $config->extraReporters[] = new MyReporter().
Implement Stromcom\HttpSmoke\Http\HttpClientInterface and register in the
container via $config->configureContainer.
The JSON report is the canonical machine-readable artefact. Markdown, HTML and GitHub step-summary outputs are derived from it.
{
"meta": {
"schema_version": 3,
"environment": "prod",
"generated_at": "2026-04-26T12:00:00+00:00",
"duration_s": 4.521,
"concurrency": 10,
"base_path": "/var/www/app",
"payloads": true,
"summary": { "total": 15, "passed": 13, "failed": 1, "skipped": 1, "success": false }
},
"groups": [
{
"name": "api.users",
"summary": { "total": 5, "passed": 4, "failed": 1, "skipped": 0 },
"tests": [
{
"label": "POST /users/ – create",
"method": "POST",
"url": "https://example.com/api/users/",
"status": "failed",
"http_code": 500,
"duration_ms": 234,
"attempts": 1,
"total_duration_ms": 234,
"session": { "label": "user lifecycle" },
"source": {
"file": "tests/SmokeHttp/api/users.php",
"absolute_path": "/var/www/app/tests/SmokeHttp/api/users.php",
"line": 24
},
"failures": ["Expected status 201, got 500"],
"skip_reason": null,
"chain_context": [/* preceding session steps */],
"request": {
"method": "POST",
"url": "https://example.com/api/users/",
"query": null,
"headers": { "Authorization": "«redacted»", "Content-Type": "application/json" },
"body_encoding": "json",
"body": { "text": "{\"name\":\"Ada\"}", "size_bytes": 14, "truncated": false, "binary": false },
"timeout_s": 10,
"cookie_jar": true,
"insecure_tls": false,
"user_agent": "StromcomSmokeTest/1.0"
},
"response": {
"status_code": 500,
"headers": { "content-type": "application/json" },
"content_type": "application/json",
"body": { "text": "{\"error\":\"boom\"}", "size_bytes": 16, "truncated": false, "binary": false },
"transport_error": null
}
}
]
}
]
}vendor/bin/http-smoke staging --output-html=build/smoke.htmlOne self-contained file — no CDN, no assets, no server. Open it in a browser or publish it as a CI artefact. It embeds the canonical JSON and renders:
- summary cards + pass/fail/skip ratio bar, environment / duration / concurrency meta,
- groups with per-group tallies; failed tests are expanded by default,
- per-test detail: method, URL, HTTP code, duration, attempts (total time incl. retries),
- all failure messages verbatim, skip reasons,
- payloads — the request exactly as sent (headers incl. the implicit
Content-Type, query parameters, body in its wire encoding) and the response (headers, pretty-printed body), plus a ready-to-run cURL reproduction, - source — the definition file and line of each test
(
tests/SmokeHttp/api/users.php:24), as plain selectable text, - chain context for session tests — every preceding step of the session (method, status, label, URL, duration) with the failing step highlighted,
- filters: full-text search (label / URL / failure message / payload / path), status (all / failed / passed / skipped), group, definition file, retried-only, sessions-only, plus expand/collapse all and “show payloads”.
Dark mode follows the OS setting. The layout logic lives in
src/Reporting/templates/report.html; HtmlReporter only injects the JSON.
Payloads land in both the JSON and the HTML report, so treat those artefacts as
sensitive. Sensitive headers (Authorization, Cookie, Set-Cookie,
X-Api-Key, …) are replaced with «redacted», bodies are truncated at 16 kB and
binary bodies are reported as size only.
vendor/bin/http-smoke prod --output-html=report.html --no-payloads
vendor/bin/http-smoke prod --output-html=report.html --payload-limit=65536$config->includePayloads = false; // drop request/response blocks entirely
$config->payloadMaxBytes = 65536; // per-body cap
$config->redactedHeaders = ['authorization', 'x-tenant-secret'];See examples/ for ready-to-run test suites and a sample
smoke.config.php / smokeHttp.json.
MIT — see LICENSE.