An embedded HTTP mock server for PHP integration tests. Inspired by Rust's wiremock crate and similar to WireMock, but runs entirely in PHP with no Docker or JVM required.
composer require --dev qoliber/servermockuse Qoliber\ServerMock\Mock;
use Qoliber\ServerMock\MockServer;
// Start a mock server on a random port
$server = MockServer::start();
// Define a stub
Mock::get('/api/users/1')
->respondJson(['id' => 1, 'name' => 'John Doe'])
->mount($server);
// Make requests against it
$response = file_get_contents($server->url('/api/users/1'));
// Returns: {"id":1,"name":"John Doe"}
// Clean up
$server->shutdown();Configure multiple stubs at once - great for complex test scenarios:
$server->configure([
Mock::get('/api/users')->respondJson(['users' => []]),
Mock::get('/api/products')->respondJson(['products' => ['SKU001']]),
Mock::post('/api/orders')->respondJson(['orderId' => '12345'], 201),
]);Create reusable test scenarios:
use Qoliber\ServerMock\Scenario;
$checkoutScenario = Scenario::create('checkout')
->stub(Mock::get('/api/cart')->respondJson(['items' => [], 'total' => 0]))
->stub(Mock::post('/api/cart/add')->respondJson(['success' => true]))
->stub(Mock::post('/api/checkout')->respondJson(['orderId' => 'ORD-001']));
$server->applyScenario($checkoutScenario);Or use the fluent API:
$scenario = Scenario::create('user-flow');
$scenario->get('/api/profile')->respondJson(['name' => 'John']);
$scenario->post('/api/update')->respondJson(['updated' => true]);
$scenario->applyTo($server);Auto-detect format by extension:
$server->loadFromFile('fixtures/api-stubs.yaml'); // Auto-detects YAML
$server->loadFromFile('fixtures/api-stubs.json'); // Auto-detects JSON$server->loadFromJson('fixtures/stubs.json');{
"stubs": [
{
"method": "GET",
"path": "/api/users",
"response": { "status": 200, "body": "{\"users\": []}" }
}
]
}composer require symfony/yaml$server->loadFromYaml('fixtures/stubs.yaml');# fixtures/stubs.yaml
stubs:
- method: GET
path: /api/users
response:
status: 200
body: '{"users": []}'
- method: POST
path: /api/orders
headers:
Content-Type: application/json
response:
status: 201
body: '{"orderId": "123"}'
- method: GET
pathRegex: '#^/api/products/\d+$#'
response:
body: '{"id": "matched"}'composer require yosymfony/toml$server->loadFromToml('fixtures/stubs.toml');# fixtures/stubs.toml
[[stubs]]
method = "GET"
path = "/api/users"
[stubs.response]
status = 200
body = '{"users": []}'
[[stubs]]
method = "POST"
path = "/api/orders"
[stubs.response]
status = 201
body = '{"orderId": "123"}'$server->loadFromPhp('fixtures/stubs.php');<?php
return [
'stubs' => [
[
'method' => 'GET',
'path' => '/api/config',
'response' => [
'body' => json_encode(['timestamp' => time()]),
],
],
],
];$server->loadFromArray([
'stubs' => [
[
'method' => 'GET',
'path' => '/api/users',
'response' => ['body' => '[]'],
],
[
'method' => 'GET',
'pathRegex' => '#^/api/users/\d+$#',
'response' => ['body' => '{"id": "matched"}'],
],
],
]);use Qoliber\ServerMock\ServerMockTestCase;
use Qoliber\ServerMock\Mock;
class MyIntegrationTest extends ServerMockTestCase
{
public function testPaymentGateway(): void
{
Mock::post('/v1/charges')
->respondJson(['id' => 'ch_123', 'status' => 'succeeded'], 201)
->mount($this->mockServer);
$paymentService = new PaymentService($this->getMockServerUri());
$result = $paymentService->charge(1000, 'usd');
$this->assertTrue($result->isSuccessful());
}
}use PHPUnit\Framework\TestCase;
use Qoliber\ServerMock\ServerMockTrait;
use Qoliber\ServerMock\Mock;
class MyTest extends TestCase
{
use ServerMockTrait;
protected function setUp(): void
{
parent::setUp();
$this->setUpServerMock();
}
protected function tearDown(): void
{
$this->tearDownServerMock();
parent::tearDown();
}
}Dynamic responses based on request data:
Mock::post('/api/echo')
->respondJsonTemplate('{"path": "{{request.path}}", "method": "{{request.method}}"}')
->mount($server);
Mock::post('/api/users')
->respondJsonTemplate('{"id": "{{uuid}}", "name": "{{request.jsonBody.name}}"}')
->mount($server);Available template variables:
{{request.path}}- Request path{{request.method}}- HTTP method{{request.query.paramName}}- Query parameter{{request.header.HeaderName}}- Header value{{request.jsonBody.field}}- JSON body field (supports nesting:jsonBody.user.name){{uuid}}- Random UUID{{now}}- Unix timestamp{{nowIso}}- ISO 8601 timestamp{{randomInt}}- Random integer
Mock::graphql()
->withOperationName('GetUser')
->respondGraphQL(['user' => ['id' => '1', 'name' => 'John']])
->mount($server);
Mock::graphql()
->withMutation()
->withQueryContaining('createUser')
->respondGraphQL(['createUser' => ['id' => '123']])
->mount($server);
Mock::graphql()
->withVariable('userId', '999')
->respondGraphQL(['user' => ['id' => '999']])
->mount($server);
// Error responses
Mock::graphql()
->withOperationName('FailingOp')
->respondGraphQLError('Something went wrong', 'INTERNAL_ERROR')
->mount($server);Mock::get('/exact/path')
Mock::getMatching('#^/users/\d+$#') // Regex
use Qoliber\ServerMock\Matcher\PathMatcher;
Mock::given(new MethodMatcher('GET'))
->and(PathMatcher::startsWith('/api/'))Mock::get('/secured')
->withHeader('Authorization') // Header exists
->withHeader('X-API-Key', 'secret') // Header equals valueMock::get('/search')
->withQueryParam('q') // Param exists
->withQueryParam('page', '1') // Param equals valueMock::post('/echo')->withBody('exact content')
Mock::post('/search')->withBodyContaining('important')
Mock::post('/validate')->withBodyMatching('/^order-\d{4}$/')
Mock::post('/api/order')->withJsonBody(['type' => 'premium'])
Mock::post('/webhook')->withJsonPath('event.type', 'payment.success')use Qoliber\ServerMock\Response;
Mock::get('/ok')->respondOk('Body text')
Mock::get('/not-found')->respondNotFound()
Mock::get('/error')->respondServerError()
Mock::get('/json')->respondJson(['key' => 'value'])
Mock::post('/create')->respondJson(['id' => 1], 201)
Mock::get('/custom')
->respondWith(
Response::create(201)
->withHeader('X-Custom', 'value')
->withBody('{"custom": true}')
)use Qoliber\ServerMock\ResponseSequence;
$sequence = ResponseSequence::create()
->thenJson(['status' => 'pending'])
->thenJson(['status' => 'processing'])
->thenJson(['status' => 'complete']);
Mock::get('/job/status')
->respondWithSequence($sequence)
->mount($server);
// With repeat
$sequence = ResponseSequence::create()
->thenJson(['cycle' => 1])
->thenJson(['cycle' => 2])
->repeat(); // Loops: 1, 2, 1, 2...Mock::get('/slow-endpoint')
->respondOk('Finally!')
->withDelay(2000) // 2 second delay
->mount($server);
Mock::get('/api/resource')
->respondJson(['source' => 'specific'])
->withPriority(10) // Higher priority wins
->mount($server);$stub = Mock::get('/tracked')->respondOk()->mount($server);
$client->get($server->url('/tracked'));
$client->get($server->url('/tracked'));
$requests = $server->getRecordedRequests();
$count = $server->getRequestCount(); // 2
$server->assertCalled($stub); // At least once
$server->assertNotCalled($stub); // Never called
$server->verify($stub, 2); // Exactly 2 timesCreate scenarios where responses change based on previous requests:
use Qoliber\ServerMock\State\StateMachine;
$scenario = StateMachine::create('order-flow')
->initialState('empty')
->when('empty')
->on(Mock::post('/cart/add'))
->respondJson(['success' => true, 'itemCount' => 1])
->transitionTo('has-items')
->when('has-items')
->on(Mock::get('/cart'))
->respondJson(['items' => ['SKU-001']])
->stay() // Stay in current state
->when('has-items')
->on(Mock::post('/checkout'))
->respondJson(['orderId' => 'ORD-123'])
->transitionTo('ordered')
->when('ordered')
->on(Mock::get('/order/ORD-123'))
->respondJson(['status' => 'confirmed'])
->getMachine();
$server->addStateMachine($scenario);
// Now requests trigger state transitions
$client->post('/cart/add'); // empty -> has-items
$client->get('/cart'); // stays in has-items
$client->post('/checkout'); // has-items -> ordered
// Inspect and control state
$state = $server->getScenarioState('order-flow'); // 'ordered'
$server->setScenarioState('order-flow', 'empty'); // Reset manually
$server->resetScenario('order-flow'); // Reset to initial
$server->resetAllScenarios(); // Reset allSimulate network failures and errors for resilience testing:
use Qoliber\ServerMock\Fault\Fault;
// Random server errors (500) with 30% probability
Mock::get('/api/flaky')
->withFault(Fault::randomServerError(0.3, 'Service temporarily unavailable'))
->respondJson(['data' => 'ok'])
->mount($server);
// Fixed delay (simulates slow network)
Mock::get('/api/slow')
->withFault(Fault::fixedDelay(500)) // 500ms delay
->respondJson(['data' => 'slow'])
->mount($server);
// Random delay between min and max
Mock::get('/api/variable')
->withFault(Fault::randomDelay(100, 1000)) // 100-1000ms
->respondJson(['data' => 'variable'])
->mount($server);
// Connection reset (simulates dropped connection)
Mock::get('/api/unstable')
->withFault(Fault::connectionReset(0.2)) // 20% chance
->respondJson(['data' => 'ok'])
->mount($server);
// Empty response (simulates truncated response)
Mock::get('/api/broken')
->withFault(Fault::emptyResponse(0.1))
->respondJson(['data' => 'ok'])
->mount($server);
// Malformed JSON (simulates corrupt response)
Mock::get('/api/corrupt')
->withFault(Fault::malformedJson(0.1))
->respondJson(['data' => 'ok'])
->mount($server);
// Timeout (simulates request timeout)
Mock::get('/api/timeout')
->withFault(Fault::timeout(0.1, 30000)) // 10% chance, 30s delay
->respondJson(['data' => 'ok'])
->mount($server);
// Chain multiple faults
Mock::get('/api/chaos')
->withFault(Fault::chain(
Fault::randomDelay(50, 200),
Fault::randomServerError(0.1)
))
->respondJson(['data' => 'ok'])
->mount($server);Run ServerMock as a standalone server:
# Start server on default port (8080)
./vendor/bin/servermock start
# Custom host and port
./vendor/bin/servermock start --host 0.0.0.0 --port 9090
# Load stubs from config file
./vendor/bin/servermock start --config fixtures/stubs.json
# Show help
./vendor/bin/servermock helpForward unmatched requests to a real server:
$server->enableProxy('https://api.example.com', record: true);
// Requests that don't match any stub are forwarded
// With record: true, responses are recorded for later inspection
$recordings = $server->getProxyRecordings();
$server->disableProxy();| Feature | Guzzle MockHandler | Docker + WireMock | ServerMock |
|---|---|---|---|
| Real HTTP | No | Yes | Yes |
| No Docker | Yes | No | Yes |
| No JVM | Yes | No | Yes |
| Fast startup | Yes | No | Yes |
| Works with any HTTP client | No | Yes | Yes |
| Bulk configuration | No | Yes | Yes |
| File-based config | No | Yes | Yes |
| Stateful mocking | No | Yes | Yes |
| Fault injection | No | Yes | Yes |
| CLI standalone | No | Yes | Yes |
- PHP 8.1+
MIT
Developed by Qoliber