Skip to content

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:

php
// 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:

text
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 evaluator

Here is what happens step by step:

  1. The middleware calls the firewall's decide() method on the incoming request
  2. If the request passes (is not blocked), the middleware creates a RequestContext and attaches it to the request as a PSR-7 attribute named phirewall.context
  3. Your handler receives the request with the attached context
  4. 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's keyExtractor; 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).
  5. After your handler returns a response, the middleware processes each recorded signal through the matching counter engine (fail2ban or allow2ban)
  6. 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:

php
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:

php
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:

php
$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():

php
use Flowd\Phirewall\KeyExtractors;

$config->allow2ban->add(
    'expensive-endpoint',
    threshold: 5,
    period: 300,
    banSeconds: 3600,
    key: fn($request): ?string => null,
);

In the handler:

php
$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():

php
// 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.

MethodSignatureDescription
recordFailure()(string $ruleName, ?string $key = null): voidRecord a fail2ban failure signal
recordHit()(string $ruleName, ?string $key = null): voidRecord an allow2ban hit signal
getResult()(): FirewallResultAccess the pre-handler firewall decision
getRecordedSignals()(): list<RecordedSignal>Get all recorded signals (failures and hits)
hasRecordedSignals()(): boolWhether any signals have been recorded

Constants:

ConstantValueDescription
RequestContext::ATTRIBUTE_NAME'phirewall.context'PSR-7 request attribute key

recordFailure() / recordHit() Parameters

Both methods take the same parameters:

ParameterTypeDescription
$ruleNamestringMust match the name of a configured fail2ban->add() rule (for recordFailure()) or allow2ban->add() rule (for recordHit())
$key?stringThe 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()).

PropertyTypeDescription
$ruleNamestringThe fail2ban or allow2ban rule this signal is recorded against
$banTypeBanTypeBanType::Fail2Ban (from recordFailure()) or BanType::Allow2Ban (from recordHit())
$key?stringThe 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:

php
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 (?->):

php
$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
<?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 FirewallError event 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

ApproachWhen to UseExample
Filter predicateFailures determined by request properties aloneBlock every POST to /admin
RequestContextFailures require application logicBan 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:

php
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));
    }
}