Request Context
The RequestContext API lets your application signal post-handler events (fail2ban failures via recordFailure() and allow2ban hits via recordHit()) from inside the request handler, after the firewall has already passed the request through. This solves a fundamental limitation: standard fail2ban and allow2ban filters run before your handler, so they cannot see whether credentials were valid, whether a payment failed, or whether an API key was revoked.
The Problem
Standard fail2ban rules use a filter predicate that evaluates the incoming request. This works for simple patterns (like "every POST to /login counts as a failure attempt"), but it cannot distinguish between successful and failed logins:
// Problem: this counts EVERY POST to /login, including successful logins
$config->fail2ban->add('login',
threshold: 5, period: 300, ban: 3600,
filter: fn($request) => $request->getMethod() === 'POST'
&& $request->getUri()->getPath() === '/login',
);With RequestContext, your handler verifies the credentials first, then signals a failure only when authentication actually fails. This gives you precise control over what counts as a failure.
How It Works
The flow has three stages:
1. Middleware evaluates request
└── Attaches a mutable RequestContext to the PSR-7 request attribute
2. Handler runs your application logic
└── Retrieves the context and calls recordFailure() / recordHit() if needed
3. Middleware runs post-handler processing
└── Routes each recorded signal to its fail2ban or allow2ban evaluatorHere is what happens step by step:
- The middleware calls the firewall's
decide()method on the incoming request - If the request passes (is not blocked), the middleware creates a
RequestContextand attaches it to the request as a PSR-7 attribute namedphirewall.context - Your handler receives the request with the attached context
- If your handler determines that the request represents a failure (wrong password, invalid API key, etc.), it calls
$context->recordFailure('rule-name'). For an allow2ban hit, it calls$context->recordHit('rule-name')instead. The key is derived from the matching rule'skeyExtractor; pass an explicit second argument ($key) only when the handler knows a value the firewall cannot derive (e.g. a user id from a session). - After your handler returns a response, the middleware processes each recorded signal through the matching counter engine (fail2ban or allow2ban)
- If the count crosses the threshold, the key is banned for future requests
Setup
Configure a fail2ban rule with a filter that always returns false. This means the firewall never counts failures automatically; your handler does it instead:
use Flowd\Phirewall\Config;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Middleware;
use Flowd\Phirewall\Store\InMemoryCache;
use Psr\Http\Message\ServerRequestInterface;
$config = new Config(new InMemoryCache());
// The filter returns false; no request is counted automatically.
// Failures are recorded programmatically via RequestContext in your handler.
$config->fail2ban->add('login-failures',
threshold: 3,
period: 300,
ban: 3600,
filter: fn(ServerRequestInterface $request): bool => false,
);
$middleware = new Middleware($config);Why filter: fn() => false?
The filter still exists because the fail2ban rule requires one. Setting it to always return false means the pre-handler phase never counts any request as a failure; all failure counting is deferred to your handler via RequestContext.
Recording Failures in Your Handler
Retrieve the RequestContext from the request attribute and call recordFailure(). The second argument is optional: when omitted, the firewall reuses the rule's own keyExtractor against this request, so the handler doesn't need to know whether the rule keys on IP, header, or anything else:
use Flowd\Phirewall\Context\RequestContext;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class LoginHandler implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
$username = $request->getParsedBody()['username'] ?? '';
$password = $request->getParsedBody()['password'] ?? '';
if (!$this->authenticate($username, $password)) {
// Retrieve the RequestContext attached by the middleware
/** @var RequestContext|null $context */
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
// Signal the failure; the firewall derives the key from the
// rule's own keyExtractor. Use the null-safe operator for safety.
$context?->recordFailure('login-failures');
return new JsonResponse(['error' => 'Invalid credentials'], 401);
}
return new JsonResponse(['success' => true, 'user' => $username], 200);
}
}If the handler knows a discriminator that the firewall cannot derive from the request alone (for example, a user id looked up in a session store), pass it as the second argument:
$context?->recordFailure('login-failures', $userIdFromSession);Rule name must match
The first parameter to recordFailure() must exactly match the name you used in $config->fail2ban->add() (and likewise recordHit() must match a $config->allow2ban->add() rule). If no matching rule is found, the signal is silently ignored.
Recording allow2ban Hits
recordHit() is the allow2ban counterpart of recordFailure(). The same context records allow2ban hits: use it to count handler-observable events the pre-handler path cannot see (an expensive operation completed, a webhook delivered a duplicate payload, a third-party API quota was charged) so the count can drive an allow2ban threshold ban. It mirrors recordFailure(), and $key is likewise optional: omit it to reuse the matching rule's key extractor on the current request.
First, configure an allow2ban rule. To make the rule count only the events recorded by the handler (not every request), have the rule's keyExtractor return null pre-handler; the firewall then skips counting until the handler signals an explicit key via recordHit():
use Flowd\Phirewall\KeyExtractors;
$config->allow2ban->add(
'expensive-endpoint',
threshold: 5,
period: 300,
banSeconds: 3600,
key: fn($request): ?string => null,
);In the handler:
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
if ($context !== null && $this->operationWasExpensive($request)) {
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$context->recordHit('expensive-endpoint', $ip);
}If the rule's keyExtractor returns a value pre-handler (the common case), the second argument to recordHit() can be omitted; the firewall derives the key the same way it does for recordFailure():
// Omitting $key reuses the rule's own key extractor on this request.
$context?->recordHit('expensive-endpoint');Note that when the rule's keyExtractor returns a value pre-handler, both the pre-handler counter and the handler's recordHit() increment the counter, so the threshold should account for the doubled count.
Recorded failures and hits are processed together after your handler returns; retrieve them all with getRecordedSignals().
API Reference
RequestContext
The RequestContext class is a mutable recorder that the middleware attaches to the PSR-7 request.
| Method | Signature | Description |
|---|---|---|
recordFailure() | (string $ruleName, ?string $key = null): void | Record a fail2ban failure signal |
recordHit() | (string $ruleName, ?string $key = null): void | Record an allow2ban hit signal |
getResult() | (): FirewallResult | Access the pre-handler firewall decision |
getRecordedSignals() | (): list<RecordedSignal> | Get all recorded signals (failures and hits) |
hasRecordedSignals() | (): bool | Whether any signals have been recorded |
Constants:
| Constant | Value | Description |
|---|---|---|
RequestContext::ATTRIBUTE_NAME | 'phirewall.context' | PSR-7 request attribute key |
recordFailure() / recordHit() Parameters
Both methods take the same parameters:
| Parameter | Type | Description |
|---|---|---|
$ruleName | string | Must match the name of a configured fail2ban->add() rule (for recordFailure()) or allow2ban->add() rule (for recordHit()) |
$key | ?string | The discriminator key to count against (e.g., IP address, username). Optional: when omitted (null), the firewall applies the matching rule's own key extractor to the current request, so your handler does not need to repeat the rule's keying logic. |
RecordedSignal
An immutable value object representing a single recorded signal (the elements returned by getRecordedSignals()).
| Property | Type | Description |
|---|---|---|
$ruleName | string | The fail2ban or allow2ban rule this signal is recorded against |
$banType | BanType | BanType::Fail2Ban (from recordFailure()) or BanType::Allow2Ban (from recordHit()) |
$key | ?string | The discriminator key override, or null to defer to the matching rule's key extractor |
Accessing the Firewall Decision
The RequestContext also gives your handler access to the pre-handler firewall decision via getResult(). This returns a FirewallResult object:
use Flowd\Phirewall\Context\RequestContext;
/** @var RequestContext|null $context */
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
if ($context !== null) {
$result = $context->getResult();
$result->outcome->value; // 'pass', 'safelisted', etc.
$result->isPass(); // true if the request was allowed through
$result->rule; // Name of the matching rule (null if simply passed)
}This is useful for:
- Logging: record which safelist rule matched a request
- Conditional behavior: adjust handler logic based on whether the request was safelisted
- Admin dashboards: display the firewall decision alongside other request metadata
Null-Safe Access Pattern
When your handler might run without the Phirewall middleware in the stack (for example, in unit tests or a different environment), always use PHP's null-safe operator (?->):
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$context?->recordFailure('login-failures');
$context?->recordHit('expensive-endpoint');If the middleware is not present, $context is null and the calls are silently skipped: no errors, no side effects. This makes your handler safe to use with or without Phirewall.
Complete Example
A full, runnable example showing login protection with post-handler failure signaling:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Context\RequestContext;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Middleware;
use Flowd\Phirewall\Store\InMemoryCache;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
// 1. Configure fail2ban with a filter that never matches
$config = new Config(new InMemoryCache());
$config->fail2ban->add('login-failures',
threshold: 3,
period: 300,
ban: 3600,
filter: fn(ServerRequestInterface $request): bool => false,
);
$middleware = new Middleware($config, new Psr17Factory());
// 2. Handler that checks credentials and signals failures
$handler = new class implements RequestHandlerInterface {
public function handle(ServerRequestInterface $request): ResponseInterface
{
$username = $request->getHeaderLine('X-Username');
$password = $request->getHeaderLine('X-Password');
if ($username !== 'admin' || $password !== 'secret') {
/** @var RequestContext|null $context */
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$context?->recordFailure('login-failures');
return new Response(401, ['Content-Type' => 'application/json'],
json_encode(['error' => 'Invalid credentials'])
);
}
return new Response(200, ['Content-Type' => 'application/json'],
json_encode(['success' => true])
);
}
};
// 3. Simulate failed login attempts
$attackerIp = '10.0.0.50';
for ($i = 1; $i <= 3; ++$i) {
$request = new ServerRequest('POST', '/login',
['X-Username' => 'admin', 'X-Password' => 'wrong'],
null, '1.1', ['REMOTE_ADDR' => $attackerIp]
);
$response = $middleware->process($request, $handler);
echo "Attempt {$i}: {$response->getStatusCode()}\n";
// Output: 401, 401, 401
}
// 4. Next request is banned (even with correct credentials)
$request = new ServerRequest('POST', '/login',
['X-Username' => 'admin', 'X-Password' => 'secret'],
null, '1.1', ['REMOTE_ADDR' => $attackerIp]
);
$response = $middleware->process($request, $handler);
echo "Attempt 4: {$response->getStatusCode()}\n";
// Output: 403 (banned)
// 5. Other IPs are not affected
$request = new ServerRequest('POST', '/login',
['X-Username' => 'admin', 'X-Password' => 'secret'],
null, '1.1', ['REMOTE_ADDR' => '10.0.0.200']
);
$response = $middleware->process($request, $handler);
echo "Other IP: {$response->getStatusCode()}\n";
// Output: 200 (allowed)Fail-Open Behavior
If an error occurs while processing recorded failure signals (for example, a cache connection failure), the middleware follows the configured fail-open/fail-closed behavior:
- Fail-open (default): errors are caught, a
FirewallErrorevent is dispatched for logging, and the handler's response is returned normally - Fail-closed (
$config->setFailOpen(false)): exceptions propagate to the caller
This means that even if the cache backend goes down after your handler runs, the user still receives the handler's response. The failure signal is lost, but the application remains available.
See Getting Started: Fail-Open / Fail-Closed for configuration.
When to Use RequestContext vs. Filter
| Approach | When to Use | Example |
|---|---|---|
| Filter predicate | Failures determined by request properties alone | Block every POST to /admin |
| RequestContext | Failures require application logic | Ban after 3 failed password attempts |
Use the filter when:
- The request URI, method, or headers are enough to determine failure
- You do not need to inspect the response or run business logic
Use RequestContext when:
- You need to verify credentials before deciding if the request is a failure
- The failure depends on a database lookup, API call, or response status
- You want to count only actual failures, not all requests to an endpoint
Testing
Verify that failures recorded via RequestContext trigger bans:
use PHPUnit\Framework\TestCase;
use Flowd\Phirewall\BanType;
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Context\RequestContext;
use Flowd\Phirewall\Http\Firewall;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Middleware;
use Flowd\Phirewall\Store\InMemoryCache;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class RequestContextTest extends TestCase
{
public function testFailuresRecordedViaContextTriggerBan(): void
{
$config = new Config(new InMemoryCache());
$config->fail2ban->add('test-rule',
threshold: 2, period: 300, ban: 3600,
filter: fn($request): bool => false,
);
$middleware = new Middleware($config, new Psr17Factory());
$firewall = new Firewall($config);
// Handler that always records a failure
$handler = new class implements RequestHandlerInterface {
public function handle(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
{
$context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
$context?->recordFailure('test-rule');
return new Response(401);
}
};
$ip = '10.0.0.1';
// 2 failures should trigger the ban
for ($i = 0; $i < 2; ++$i) {
$request = new ServerRequest('POST', '/login', [], null, '1.1', ['REMOTE_ADDR' => $ip]);
$middleware->process($request, $handler);
}
// Verify the IP is now banned
$this->assertTrue($firewall->isBanned('test-rule', $ip, BanType::Fail2Ban));
}
}Related Pages
- Fail2Ban & Allow2Ban - fail2ban rule configuration and filter predicates
- Track & Notifications - passive counting without blocking
- Observability - events and diagnostics
- Getting Started - full setup walkthrough