As a developer, I often find myself working on applications that interact with third-party APIs or external services. While it's essential to test such integrations, depending on real external services during testing can introduce delays, flakiness, or even unexpected costs. That's why mocking HTTP requests is such a powerful concept in testing.
A while back, I released aksoyih/http-mock — a simple PHP package for mocking HTTP responses in tests. It worked well for basic use cases, but as my projects grew, I kept running into limitations: no pattern matching, no way to verify which requests were actually made, and no integration with real HTTP clients like Guzzle or Symfony HttpClient.
So I rewrote it from scratch. A modular, PHP 8.4+ library with flexible request matching, full verification support, and adapters for the HTTP clients you actually use.
What Changed
v1 was a simple key-value store: you registered a method + URL pair and got a response back. v2 is a completely different architecture:
- Static facade API instead of instance-based — no more passing
$mockaround - Pattern matching — wildcard, regex, and callable matchers instead of exact URLs only
- Request verification — assert that endpoints were called (or not), with exact counts
- Real HTTP client adapters — PSR-18, Guzzle, and Symfony HttpClient out of the box
- Explicit registration with
->register()instead of__destruct()magic
Getting Started
composer require --dev aksoyih/http-mock
The Basics
The API is built around a static facade. Define a mock, create a client, make requests:
use Aksoyih\HttpMock\HttpMock;
HttpMock::when('GET', '/users')
->willReturn(['id' => 1, 'name' => 'John'])
->withStatus(200)
->register();
$client = HttpMock::createClient(); // PSR-18 client
$response = $client->sendRequest($request);
No more instantiating objects or wiring things together. HttpMock::when() defines the mock, ->register() activates it, and HttpMock::createClient() gives you a real PSR-18 client that returns your mocked responses.
Flexible Request Matching
This was the biggest limitation of v1. You could only match exact URLs. v2 supports four matching strategies, auto-detected from the pattern you pass:
// Exact match
HttpMock::when('GET', '/users/123')
->willReturn(['id' => 123])
->register();
// Wildcard — * matches one segment, ** matches many
HttpMock::when('GET', '/api/*/users/**')
->willReturn([])
->register();
// Regex — delimited by #
HttpMock::when('GET', '#^/users/\d+$#')
->willReturn(['found' => true])
->register();
// Callable — full control over method, URL, headers, body
HttpMock::whenMatching(fn($method, $url, $headers, $body) =>
$method === 'POST' && str_contains($url, '/admin')
)->willReturn(['access' => 'granted'])
->register();
The pattern type is detected automatically: if it contains *, it's a wildcard; if it's wrapped in #, it's regex; otherwise it's an exact match. For full control, whenMatching() gives you a closure that receives the entire request.
Request Verification
This is the feature I wished v1 had. You can now assert which endpoints were called:
HttpMock::assertCalled('GET', '/users');
HttpMock::assertCalledTimes('POST', '/users', 3);
HttpMock::assertNotCalled('DELETE', '/users');
Or attach expectations directly to mock definitions:
HttpMock::when('POST', '/orders')
->willReturn(['id' => 42])
->expect(times: 1)
->register();
// ... run your code ...
HttpMock::verify(); // throws VerificationException if POST /orders wasn't called exactly once
You can use times, atLeast, and atMost for flexible constraints.
Works With Your HTTP Client
v1 returned raw arrays. v2 gives you real HTTP client instances that your application code can use without modification:
// PSR-18 (works with any PSR-18 compatible library)
$client = HttpMock::createClient();
// Guzzle
$handler = HttpMock::createGuzzleHandler();
$stack = HandlerStack::create($handler);
$guzzle = new \GuzzleHttp\Client(['handler' => $stack]);
// Symfony HttpClient
$client = HttpMock::createSymfonyClient();
This means you can inject mock clients into your services during testing without changing any application code. Your service expects a ClientInterface? Hand it HttpMock::createClient(). It expects a Guzzle client? Use the handler stack. It's that simple.
Quick Setup With fake()
For simple cases where you just need a few mocked endpoints, fake() is a one-liner:
HttpMock::fake([
'GET /users/*' => HttpMock::response(['id' => 1], 200),
'POST /users' => HttpMock::response(['created' => true], 201),
]);
Strict by Default
Unmatched requests throw an exception with a helpful message listing all defined mocks:
UnmatchedRequestException: No mock defined for GET /users/999.
Defined mocks:
- GET /users/* (matched 3 times)
- POST /users (matched 0 times)
You can change this behavior:
HttpMock::onUnmatched(HttpMock::DEFAULT_404); // return 404
HttpMock::onUnmatched(fn($method, $url) => [ // custom handler
'status' => 503, 'headers' => [], 'body' => 'down',
]);
Test Isolation
Forgetting to clean up between tests is the most common source of flaky mock tests. v2 provides a PHPUnit trait that handles this automatically:
use Aksoyih\HttpMock\Testing\MocksHttp;
class OrderServiceTest extends TestCase
{
use MocksHttp; // resets after every test, automatically
}
Or use scoped() for one-off isolation with guaranteed cleanup, even if an exception is thrown:
HttpMock::scoped(function () {
HttpMock::when('GET', '/users')->willReturn([])->register();
// ... test code ...
}); // reset happens here, always
Why v2?
The original package was a quick solution to a real problem. v2 is what I wish I had built from the start:
| v1 | v2 | |
|---|---|---|
| PHP | 8.1+ | 8.4+ |
| API | Instance-based | Static facade |
| Matching | Exact URL only | Wildcard, regex, callable |
| Verification | None | Full assertion API |
| HTTP Clients | Raw arrays | PSR-18, Guzzle, Symfony |
| Registration | __destruct() magic |
Explicit register() |
| Test isolation | Manual reset() |
MocksHttp trait, scoped() |
| Static analysis | None | PHPStan level 9 |
Wrapping Up
HTTP Mock v2 is a complete rewrite focused on what matters for real-world testing: flexible matching, proper verification, and integration with the HTTP clients your application actually uses.
If you were using v1, the upgrade path is straightforward — the namespace is the same, but the API is entirely new and much more capable. Check out the repository for the full documentation and examples.
Give it a try and let me know what you think.
Happy testing!
http-mock
A PHP library for mocking HTTP clients with customizable responses and behaviors