doppiogancio / mocked-client
Describe a mocked HTTP server once, then test against it with any client: PSR-18, Guzzle, HTTPlug or Symfony
Requires
- php: ^8.2
- ext-json: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/log: ^2.0|^3.0
Requires (Dev)
- doctrine/coding-standard: ^12.0
- guzzlehttp/guzzle: ^7.8
- nyholm/psr7: ^1.8
- php-http/httplug: ^2.4
- php-http/promise: ^1.3
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- slevomat/coding-standard: ^8.15
- squizlabs/php_codesniffer: ^3.9
- symfony/http-client: ^6.4|^7.0
- symfony/var-dumper: ^6.4
Suggests
- guzzlehttp/guzzle: To mock a GuzzleHttp\Client via MockServer::guzzle()
- php-http/httplug: To mock an Http\Client\HttpAsyncClient via MockServer::httplug()
- symfony/http-client: To mock a Symfony HttpClientInterface via MockServer::symfonyCallback()
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v5.0.0
- v5.0.0-beta.1
- v4.1.4
- v4.1.3
- v4.1.2
- v4.1.1
- v4.1.0
- v4.0.0
- v3.x-dev
- v3.1.11
- v3.1.10
- v3.1.9
- v3.1.8
- v3.1.6
- v3.1.5
- v3.1.4
- v3.1.3
- v3.1.2
- v3.1.1
- v3.1.0
- v3.0.1
- v2.0.7
- v2.0.6
- v2.0.5
- v2.0.4
- v2.0.3
- v2.0.2
- v2.0.1
- v2.0.0
- dev-v5-modernization
- dev-codex/add-agent.md-documentation
- dev-codex/add-brinid-column-to-station
- dev-codex/suggest-improvements
- dev-callback-route-builder
- dev-init-middlewares
- dev-small-cs-fix
- dev-ISSUE-28-lazily-build-the-handler
- dev-ISSUE-25-VERSION-3x-server-params-missing-in-the-server-request
- dev-php8
- dev-branch-v2.0.1
This package is auto-updated.
Last update: 2026-11-05 19:46:35 UTC
README
Mocked Client
Describe a server. Get any client.
Test the code that talks to an HTTP API, without the HTTP API. You describe the endpoints once, then take the client the code under test expects: PSR-18, Guzzle, HTTPlug or Symfony.
Note
This README documents v5. Coming from v4? Start with the upgrade guide. The v4 documentation lives in the v4.1.4 tag.
// describe the server... $mock = MockServer::create(); $mock->get('/country/IT')->reply(MockResponse::json(['code' => 'IT', 'name' => 'Italy'])); // ...then take the client your code expects $client = $mock->guzzle(); // or ->psr18(), ->httplug(), ->symfonyCallback()
That is the whole model, and it is why the class is called MockServer while the package is called mocked client: you describe one server, and it hands out as many clients as you need, all answering from the same stubs and recording to the same journal.
Anything you did not stub fails immediately, with a message that names the request and tells you why every stub refused it. Your tests end up describing exactly the traffic they depend on, and nothing else.
Contents
- Why this library
- Install
- A complete example
- How it works
- Matching a request
- Building a response
- Checking what was requested
- Choosing a client
- Failing fast
- Documentation
Why this library
Most HTTP mocks are a queue: you push responses and they come back in order, so your test silently depends on how many calls your code makes and in which sequence. Refactor the code, break the test.
This one is a router. You describe endpoints, the way the real API is described, and the order of calls stops mattering.
Guzzle MockHandler |
php-http/mock-client |
Symfony MockHttpClient |
Mocked Client | |
|---|---|---|---|---|
| How a response is chosen | a queue, consumed in call order | a queue, consumed in call order | a queue, or a callback you write | routed by the request |
| Order of calls matters | yes | yes | with the queue form | no |
| Path placeholders | hand rolled | hand rolled | hand rolled | built in |
| Match on query, headers, JSON body | hand rolled | hand rolled | hand rolled | built in |
| Records requests for assertions | via the history middleware | yes | yes | yes |
| Simulates transport failures | yes | yes | yes | yes |
| Clients it can back | Guzzle, and PSR-18 through it | PSR-18, HTTPlug | Symfony | PSR-18, Guzzle, HTTPlug, Symfony |
If your project uses one HTTP client and always will, the native mock of that client is a perfectly good answer, and it is one less dependency. This package earns its place when you want to describe an API rather than a call sequence, or when different parts of your codebase reach for different clients.
Install
composer require --dev doppiogancio/mocked-client
You also need a PSR-7 implementation, if your project does not already have one:
composer require --dev nyholm/psr7
Guzzle, HTTPlug and Symfony HttpClient are needed only for their respective adapters. Requires PHP 8.2 or later.
A complete example
A service that fetches a country, and the test that pins its behaviour:
final class CountryApi { public function __construct(private readonly ClientInterface $client) {} public function find(string $code): array { $request = new Request('GET', 'https://api.example.com/country/' . $code); return json_decode((string) $this->client->sendRequest($request)->getBody(), true); } }
final class CountryApiTest extends TestCase { public function testItFetchesACountry(): void { $mock = MockServer::create(); $mock->get('/country/{code}')->reply(MockResponse::file(__DIR__ . '/fixtures/country.json')); $country = (new CountryApi($mock->psr18()))->find('IT'); $this->assertSame('Italy', $country['name']); $mock->assertRequested('GET', '/country/IT'); } public function testItRetriesOnAFlakyConnection(): void { $mock = MockServer::create(); $mock->get('/country/{code}')->reply(MockResponse::sequence( MockResponse::failure('connection reset'), MockResponse::failure('connection reset'), MockResponse::json(['code' => 'IT', 'name' => 'Italy']), )); $country = (new ResilientCountryApi($mock->psr18()))->find('IT'); $this->assertSame('Italy', $country['name']); $mock->assertRequestedTimes(3, 'GET', '/country/IT'); } }
Note what the second test does: MockResponse::failure() throws a real NetworkExceptionInterface, the same thing a broken connection produces, so the retry loop is exercised rather than simulated.
How it works
A stub is one matcher chain plus one response. The two are independent, so any matcher combines with any response:
$mock->post('/orders') // method and path ->whereHeader('authorization', 'Bearer t') // narrow it ->whereJson(['sku' => 'ABC']) // narrow it further ->reply(MockResponse::status(201)); // and answer
Stubs are tried in registration order, first match wins. There is no hidden priority: the order you read is the order that applies, so the general case goes last.
$mock->get('/country')->whereQuery(['code' => 'it'])->reply(MockResponse::json($italy)); $mock->get('/country')->whereQuery(['code' => 'de'])->reply(MockResponse::json($germany)); $mock->get('/country')->reply(MockResponse::file(__DIR__ . '/fixtures/countries.json'));
Matching a request
| Method | Matches when |
|---|---|
get() post() put() patch() delete() head() options() on() |
the method and path match. Paths accept placeholders: /country/{code}, /orders/{id:\d+} |
whereQuery(['code' => 'it']) or whereQuery('code=it&page=2') |
the query string contains at least these parameters |
whereHeader('authorization', 'Bearer t') |
the header has this value. Omit the value to only require its presence |
whereJson(['sku' => 'ABC']) |
the JSON body contains at least these keys, nested ones included |
whereCallback(fn (RequestInterface $r) => ...) |
your callback returns true |
Every where*() is a subset check, so a request may carry extra parameters, headers or JSON keys. A test pins the one thing it cares about and stays readable when the payload grows.
The host is ignored, so the same stubs work whether your code calls /country or https://api.example.com/country.
Building a response
| Factory | Produces |
|---|---|
MockResponse::text('hi', 201, ['x-a' => 'b']) |
a plain body |
MockResponse::json(['code' => 'IT']) |
a JSON body, with content-type set for you |
MockResponse::file(__DIR__ . '/fixtures/c.json') |
a body read from a fixture |
MockResponse::status(204) |
a status code and nothing else |
MockResponse::psr7($response) |
a PSR-7 response you already have |
MockResponse::using(fn ($request) => ...) |
a response built from the request |
MockResponse::sequence($a, $b, $c) |
a different response on each consecutive call |
MockResponse::failure('timed out') |
a simulated transport error, for testing retries |
Every response is rebuilt for each request, body stream included, so an endpoint called twice hands out two fully readable responses.
Checking what was requested
Stubbing says what the server answers. The journal says what your code actually asked:
$mock->assertRequested('POST', '/orders'); $mock->assertRequestedTimes(3, 'GET', '/country/{code}'); $mock->assertNotRequested('DELETE', '/orders/1'); $mock->assertAllStubsUsed();
Assertions accept the same placeholders as stubs, so '/country/{code}' counts every country and '/country/IT' counts only that one. assertAllStubsUsed() catches the opposite problem: a stub written and never exercised, which usually means the test has drifted from the code.
When an assertion is not expressive enough, read the journal:
$sent = $mock->recorded('POST', '/orders')[0]; $sent->jsonBody(); // ['sku' => 'ABC', 'qty' => 2] $sent->header('content-type'); // 'application/json' $sent->response?->getStatusCode();
Choosing a client
One server, every flavour. They all share the same stubs and the same journal, so you can hand a Guzzle client to one collaborator and a PSR-18 client to another in the same test.
$mock->psr18(); // Psr\Http\Client\ClientInterface $mock->guzzle(); // GuzzleHttp\Client, with optional middlewares and client options $mock->guzzleHandler(); // a handler for a HandlerStack you build yourself $mock->httplug(); // Http\Client\HttpAsyncClient $mock->handle($request); // PSR-7 in, PSR-7 out, no client at all // Symfony's own MockHttpClient does the response building new Symfony\Component\HttpClient\MockHttpClient($mock->symfonyCallback());
Every exception raised while answering a request implements Psr\Http\Client\ClientExceptionInterface, as PSR-18 requires, so catch (ClientExceptionInterface $e) in the code under test behaves exactly as it would against a real client.
Failing fast
You do not have to know which endpoints your code calls. Write no stubs, run the test, and read the failure:
No stub matched POST /orders
Request as received:
POST /orders?dry=1
content-type: application/json
{"sku":"ABC","qty":2}
3 stub(s) registered:
GET /orders method is GET, not POST
POST /orders query is missing "dry"
POST /orders/{id} path does not match "/orders/{id}"
It prints the request as received and, for each stub, the precise reason it refused. Add the stub it asks for and repeat, so you only ever write the stubs you actually need.
Documentation
| 1. Matching requests | placeholders, query, headers, JSON body, callbacks |
| 2. Building responses | strings, JSON, fixtures, PSR-7, dynamic responses |
| 3. Sequences and failures | consecutive calls, simulated transport errors |
| 4. Asserting requests | the journal and the assertions |
| 5. Clients and adapters | PSR-18, Guzzle, HTTPlug, Symfony, containers |
| 6. Recipes | retries, pagination, auth tokens, error handling |
| 7. Upgrading from v4 | the v5 migration table |
Contributing
Issues and pull requests are welcome. Before opening one:
composer code-review # fixes the coding standard, then runs PHPStan and the tests
The suite must stay green on every supported PHP version, PHPStan runs at level 8, and the coding standard is Doctrine. Examples printed in the documentation are executed in CI, so they cannot drift from the code.
Licence
MIT. See LICENCE.md.
Built by Fabrizio Gargiulo.