Installation & Quick Start
Get Phirewall running in your PHP application in five minutes.
Prerequisites
- PHP 8.2 or higher
- Composer
- A PSR-7 (PHP Standard Recommendation for HTTP Messages) / PSR-15 (PHP Standard Recommendation for HTTP Server Middleware) compatible application. Most modern PHP frameworks support these standards, including Slim, Mezzio, Laravel, and Symfony. If you are using plain PHP, you will need a PSR-7 implementation such as
nyholm/psr7.
Installation
composer require flowd/phirewallOptional Dependencies
# PSR-7 implementation (needed for the examples below)
composer require nyholm/psr7
# For Redis-backed distributed counters (multi-server)
composer require predis/predis
# For Monolog logging integration
composer require monolog/monologTIP
APCu: Enable the PHP extension and set apc.enable_cli=1 for CLI testing.
Step 1: Choose a Storage Backend
Phirewall needs a PSR-16 (PHP Standard Recommendation for Simple Caching) cache for storing counters and ban states. Pick the backend that fits your deployment. If you are just trying Phirewall locally, start with InMemoryCache; choose a shared backend (Redis or PDO) before production.
use Flowd\Phirewall\Store\InMemoryCache;
$cache = new InMemoryCache();use Flowd\Phirewall\Store\RedisCache;
use Predis\Client as PredisClient;
$redis = new PredisClient(getenv('REDIS_URL') ?: 'redis://localhost:6379');
$cache = new RedisCache($redis, 'myapp:firewall:');use Flowd\Phirewall\Store\ApcuCache;
// Requires ext-apcu and apc.enable_cli=1 for CLI
$cache = new ApcuCache();use Flowd\Phirewall\Store\PdoCache;
// SQLite (file-based persistence)
$pdo = new PDO('sqlite:/var/lib/phirewall/cache.db');
$pdo->exec('PRAGMA journal_mode=WAL');
$cache = new PdoCache($pdo);
// MySQL or PostgreSQL (shared across servers)
// $pdo = new PDO('mysql:host=db.example.com;dbname=myapp', $dbUser, $dbPassword);
// $cache = new PdoCache($pdo);See Storage Backends for a detailed comparison.
Step 2: Create Configuration
use Flowd\Phirewall\Config;
$config = new Config($cache);
// Optional: Set a key prefix to avoid collisions
$config->setKeyPrefix('myapp');The Config constructor accepts:
| Parameter | Type | Description |
|---|---|---|
$cache | CacheInterface | Any PSR-16 cache for counters and ban states |
$eventDispatcher | ?EventDispatcherInterface | Optional PSR-14 (Event Dispatching) dispatcher for observability |
$clock | ?ClockInterface | Optional clock for deterministic testing |
Step 3: Define Rules
Every safelist, blocklist, throttle, fail2ban, and track callback receives the incoming PSR-7 ServerRequestInterface, so you can branch on the path, method, headers, and so on. The snippets below use the $config from Step 2; the $req parameter is that request.
Safelists (Allow Trusted Traffic)
Safelisted requests bypass all other rules. Use them for health checks, internal monitoring, and other trusted traffic.
$config->safelists->add('health', fn($req) => $req->getUri()->getPath() === '/health');
$config->safelists->add('metrics', fn($req) => $req->getUri()->getPath() === '/metrics');
// Safelist specific IPs or CIDR ranges
$config->safelists->ip('office', ['10.0.0.0/8', '192.168.1.0/24']);
// Safelist verified search engine bots (Googlebot, Bingbot, etc.).
// Verified via reverse DNS; pass a cache to skip repeat lookups (see Bot Detection).
$config->safelists->trustedBots();Blocklists (Deny Malicious Traffic)
Blocklisted requests are immediately rejected with 403 Forbidden.
// Block WordPress admin probes
$config->blocklists->add('scanner-probe', fn($req) => str_starts_with($req->getUri()->getPath(), '/admin-panel'));
// Block phpMyAdmin probes
$config->blocklists->add('pma-probe', fn($req) => str_contains($req->getUri()->getPath(), 'phpmyadmin'));
// Block specific IPs or CIDR ranges
$config->blocklists->ip('bad-actors', ['198.51.100.0/24']);
// Block known vulnerability scanners by User-Agent
$config->blocklists->knownScanners();
// Block requests missing standard browser headers
$config->blocklists->suspiciousHeaders();Throttling (Rate Limiting)
Throttled requests receive 429 Too Many Requests with a Retry-After header. Counting rules (throttle, fail2ban, allow2ban, track) count requests against a key, an identity that defaults to the client IP (KeyExtractors::ip(), which reads REMOTE_ADDR). Pass a key: with a KeyExtractors::* callable to count against something else; behind a proxy, set the real client IP with a resolver (see Client IP Behind Proxies).
// 100 requests per minute per IP
$config->throttles->add('ip-minute', limit: 100, period: 60);
// Sliding window (prevents double-burst at window boundaries)
$config->throttles->sliding('api-sliding', limit: 100, period: 60);
// Multi-window (burst + sustained limits in a single call)
$config->throttles->multi('api', [
1 => 5, // 5 req/s burst limit
60 => 100, // 100 req/min sustained limit
]);
// Dynamic limit from a request attribute your auth middleware sets server-side
// ($req->withAttribute('role', ...)), never a forgeable header
$config->throttles->add('role-based',
limit: fn($req) => $req->getAttribute('role') === 'admin' ? 1000 : 100,
period: 60,
);
// Enable standard rate limit headers
$config->enableRateLimitHeaders();See Rate Limiting and Dynamic Throttle for advanced usage.
Fail2Ban (Brute Force Protection)
Automatically ban clients after repeated failures. The filter evaluates each incoming request; matching requests increment a failure counter, and the client is banned as soon as the count reaches the threshold (e.g., threshold: 5 bans on the 5th matching request; that request is itself blocked).
// Ban IPs that POST to /login more than 5 times in 5 minutes
$config->fail2ban->add('login-abuse',
threshold: 5,
period: 300,
ban: 3600,
filter: fn($req) => $req->getMethod() === 'POST'
&& $req->getUri()->getPath() === '/login',
);For post-handler failure signaling (e.g., recording failures after verifying credentials), see Request Context.
Allow2Ban (Request Volume Banning)
Allow2Ban is the inverse of Fail2Ban: it counts every request for a key and bans as soon as the count reaches the threshold (threshold: 1000 bans on the 1000th request), without needing a filter predicate.
// Ban any IP that sends more than 1000 requests in 60 seconds
$config->allow2ban->add('high-volume',
threshold: 1000,
period: 60,
banSeconds: 3600,
);See Fail2Ban & Allow2Ban for details.
Track (Passive Monitoring)
Track rules count requests passively without blocking. Use them for dashboards, alerting, and analytics.
$config->tracks->add('login-attempts',
period: 3600,
filter: fn($req) => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST',
);
// Track with a threshold for alerting
$config->tracks->add('suspicious-burst',
period: 60,
filter: fn($req) => $req->getUri()->getPath() === '/login',
limit: 10, // TrackHit event includes thresholdReached flag at 10+ hits
);TIP
Track rules never block requests. They fire TrackHit events via the PSR-14 event dispatcher for observability. See Track & Notifications for details.
Step 4: Create Middleware
use Flowd\Phirewall\Middleware;
$middleware = new Middleware($config);The Middleware constructor auto-detects your PSR-17 (PHP Standard Recommendation for HTTP Factories) response factory. If auto-detection fails, pass one explicitly:
use Nyholm\Psr7\Factory\Psr17Factory;
$middleware = new Middleware($config, new Psr17Factory());You can also configure response bodies with PSR-17 factories:
$psr17Factory = new Psr17Factory();
$config->usePsr17Responses($psr17Factory, $psr17Factory);See PSR-17 Factories for custom response configuration.
Step 5: Add to Your Application
WARNING
The framework tabs below use ApcuCache, which needs the ext-apcu extension and throws without it. On a machine without APCu, swap in new InMemoryCache() to try it out, or RedisCache / PdoCache for shared production storage (see Step 1).
// In a PSR-15 pipeline (Mezzio, custom dispatchers): $app->pipe($middleware);
//
// With no framework, a minimal public/index.php front controller. Serve with:
// php -S localhost:8080 public/index.php
// Requires: composer require nyholm/psr7 nyholm/psr7-server
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
// $middleware is the Phirewall middleware from Step 4.
$psr17 = new Psr17Factory();
$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals();
// Your application handler; it runs only if Phirewall lets the request through.
$appHandler = new class ($psr17) implements RequestHandlerInterface {
public function __construct(private Psr17Factory $psr17) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->psr17->createResponse(200);
}
};
$response = $middleware->process($request, $appHandler);
// Emit the PSR-7 response.
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
foreach ($values as $value) {
header("$name: $value", false);
}
}
echo $response->getBody();// 1. Install the PSR-15 bridge:
// composer require symfony/psr-http-message-bridge nyholm/psr7
//
// 2. Create src/Factory/PhirewallFactory.php:
namespace App\Factory;
use Flowd\Phirewall\Config;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Middleware;
use Flowd\Phirewall\Store\ApcuCache;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ServerRequestInterface;
class PhirewallFactory
{
public function create(): Middleware
{
// ApcuCache requires ext-apcu (zero config, single-server)
// For multi-server: use RedisCache with predis/predis
$cache = new ApcuCache();
$config = new Config($cache);
$config->enableRateLimitHeaders();
// Safelists
$config->safelists->add('health',
fn(ServerRequestInterface $req): bool =>
$req->getUri()->getPath() === '/health'
);
$config->safelists->add('profiler',
fn(ServerRequestInterface $req): bool =>
str_starts_with($req->getUri()->getPath(), '/_profiler')
);
$config->safelists->trustedBots(cache: $cache);
// Blocklists
$config->blocklists->knownScanners();
$config->blocklists->suspiciousHeaders();
// Fail2Ban
$config->fail2ban->add('login-abuse',
threshold: 5, period: 300, ban: 3600,
filter: fn(ServerRequestInterface $req): bool =>
$req->getMethod() === 'POST'
&& $req->getUri()->getPath() === '/login',
);
// Rate limiting
$config->throttles->add('burst',
limit: 30, period: 5,
);
$config->throttles->add('global',
limit: 1000, period: 60,
);
$psr17 = new Psr17Factory();
$config->usePsr17Responses($psr17, $psr17);
return new Middleware($config, $psr17);
}
}
// 3. Register the middleware factory in config/services.yaml:
// services:
// Flowd\Phirewall\Middleware:
// factory: ['@App\Factory\PhirewallFactory', 'create']
// The listener below auto-registers via #[AsEventListener] +
// autoconfigure; the bridge factory interfaces autowire from the
// symfony/psr-http-message-bridge + nyholm/psr7 packages.
//
// 4. Create src/EventListener/PhirewallListener.php
// A two-phase listener: it blocks on kernel.request, and re-attaches
// the X-RateLimit-* headers on kernel.response (a status-only
// subscriber would silently drop them on the allowed 200 path).
// NOTE: the bridge runs Phirewall with a pass-through handler, so the
// RequestContext attribute for app-recorded fail2ban/allow2ban signals
// is NOT visible to your controllers. Use pre-handler rule filters.
namespace App\EventListener;
use Flowd\Phirewall\Middleware as PhirewallMiddleware;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class PhirewallListener
{
private const HEADERS_ATTRIBUTE = '_phirewall_headers';
public function __construct(
private readonly PhirewallMiddleware $middleware,
private readonly HttpMessageFactoryInterface $psrHttpFactory,
private readonly HttpFoundationFactoryInterface $httpFoundationFactory,
private readonly ResponseFactoryInterface $responseFactory,
) {}
#[AsEventListener(event: KernelEvents::REQUEST, priority: 256)]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$psrRequest = $this->psrHttpFactory->createRequest($event->getRequest());
$probe = new class ($this->responseFactory) implements RequestHandlerInterface {
private bool $invoked = false;
public function __construct(private readonly ResponseFactoryInterface $responseFactory) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->invoked = true;
return $this->responseFactory->createResponse(200);
}
public function wasInvoked(): bool { return $this->invoked; }
};
$psrResponse = $this->middleware->process($psrRequest, $probe);
if (!$probe->wasInvoked()) {
// The handler never ran: Phirewall produced a block/throttle response.
$event->setResponse($this->httpFoundationFactory->createResponse($psrResponse));
return;
}
// Allowed: carry Phirewall's rate-limit headers onto the real response.
if ($psrResponse->getHeaders() !== []) {
$event->getRequest()->attributes->set(self::HEADERS_ATTRIBUTE, $psrResponse->getHeaders());
}
}
#[AsEventListener(event: KernelEvents::RESPONSE)]
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
/** @var array<string, list<string>> $headers */
$headers = $event->getRequest()->attributes->get(self::HEADERS_ATTRIBUTE, []);
foreach ($headers as $name => $values) {
$event->getResponse()->headers->set($name, $values);
}
}
}// Flowd\Phirewall\Middleware is a PSR-15 middleware (process(...)), NOT a
// Laravel middleware (handle($request, $next)); registering the class
// directly throws. A thin bridge middleware (step 3) adapts it.
// Install the bridge: composer require symfony/psr-http-message-bridge nyholm/psr7
//
// 1. Create app/Providers/PhirewallServiceProvider.php:
namespace App\Providers;
use Flowd\Phirewall\Config;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Middleware as PhirewallMiddleware;
use Flowd\Phirewall\Store\ApcuCache;
use Illuminate\Support\ServiceProvider;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
class PhirewallServiceProvider extends ServiceProvider
{
public function register(): void
{
// PSR-7/PSR-17 bridge factories used by the bridge middleware.
// HttpFoundationFactory autowires (no-arg constructor).
$this->app->singleton(Psr17Factory::class);
$this->app->singleton(PsrHttpFactory::class, fn ($app) => new PsrHttpFactory(
$app->make(Psr17Factory::class), $app->make(Psr17Factory::class),
$app->make(Psr17Factory::class), $app->make(Psr17Factory::class),
));
$this->app->singleton(PhirewallMiddleware::class, function () {
// ApcuCache requires ext-apcu (zero config, single-server)
// For multi-server: use RedisCache with predis/predis
$cache = new ApcuCache();
$config = new Config($cache);
$config->setKeyPrefix(config('app.name', 'laravel'));
$config->enableRateLimitHeaders();
// Safelists
$config->safelists->add('health',
fn(ServerRequestInterface $req): bool =>
$req->getUri()->getPath() === '/health'
);
$config->safelists->trustedBots(cache: $cache);
// Blocklists
$config->blocklists->knownScanners();
$config->blocklists->suspiciousHeaders();
// Fail2Ban
$config->fail2ban->add('login-abuse',
threshold: 5, period: 300, ban: 3600,
filter: fn(ServerRequestInterface $req): bool =>
$req->getMethod() === 'POST'
&& $req->getUri()->getPath() === '/login',
);
// Rate limiting
$config->throttles->add('burst',
limit: 30, period: 5,
);
$config->throttles->add('global',
limit: 1000, period: 60,
);
$psr17 = new Psr17Factory();
$config->usePsr17Responses($psr17, $psr17);
return new PhirewallMiddleware($config, $psr17);
});
}
}
// 2. Register the provider in bootstrap/providers.php (Laravel 11+)
// or the providers array in config/app.php (Laravel 10).
//
// 3. Create app/Http/Middleware/Phirewall.php, the bridge.
// Uses a probe handler so the real Laravel response is never
// round-tripped through PSR-7 (preserves StreamedResponse /
// BinaryFileResponse) and copies Phirewall's X-RateLimit-* headers
// onto the allowed response.
// NOTE: the bridge runs Phirewall with a probe handler, so the
// RequestContext attribute for app-recorded fail2ban/allow2ban signals
// is NOT visible to your controllers. Use pre-handler rule filters.
namespace App\Http\Middleware;
use Closure;
use Flowd\Phirewall\Middleware as PhirewallEngine;
use Illuminate\Http\Request;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\HttpFoundation\Response;
final readonly class Phirewall
{
public function __construct(
private PhirewallEngine $firewall,
private PsrHttpFactory $psrHttpFactory,
private HttpFoundationFactory $httpFoundationFactory,
private Psr17Factory $psr17,
) {}
public function handle(Request $request, Closure $next): Response
{
$psrRequest = $this->psrHttpFactory->createRequest($request);
$probe = new class($this->psr17) implements RequestHandlerInterface {
private bool $invoked = false;
public function __construct(private readonly Psr17Factory $responseFactory) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->invoked = true;
return $this->responseFactory->createResponse();
}
public function wasInvoked(): bool { return $this->invoked; }
};
$psrResponse = $this->firewall->process($psrRequest, $probe);
if (! $probe->wasInvoked()) {
return $this->httpFoundationFactory->createResponse($psrResponse);
}
$response = $next($request);
foreach ($psrResponse->getHeaders() as $name => $values) {
$response->headers->set($name, $values);
}
return $response;
}
}
// 4. Register the bridge middleware (outermost):
// bootstrap/app.php (Laravel 11/12):
// ->withMiddleware(function (Middleware $middleware): void {
// $middleware->prepend(\App\Http\Middleware\Phirewall::class);
// })
//
// Or app/Http/Kernel.php (Laravel 10 and earlier):
// protected $middleware = [
// \App\Http\Middleware\Phirewall::class,
// // ...
// ];// Slim uses LIFO middleware ordering.
// Add Phirewall LAST so it executes FIRST (outermost).
use Flowd\Phirewall\Config;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Middleware as PhirewallMiddleware;
use Flowd\Phirewall\Store\ApcuCache;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Factory\AppFactory;
$app = AppFactory::create();
// ApcuCache requires ext-apcu (zero config, single-server)
// For multi-server: use RedisCache with predis/predis
$cache = new ApcuCache();
$config = new Config($cache);
$config->enableRateLimitHeaders();
// Safelists
$config->safelists->add('health',
fn(ServerRequestInterface $req): bool =>
$req->getUri()->getPath() === '/health'
);
$config->safelists->trustedBots(cache: $cache);
// Blocklists
$config->blocklists->knownScanners();
$config->blocklists->suspiciousHeaders();
// Fail2Ban
$config->fail2ban->add('login-abuse',
threshold: 5, period: 300, ban: 3600,
filter: fn(ServerRequestInterface $req): bool =>
$req->getMethod() === 'POST'
&& $req->getUri()->getPath() === '/login',
);
// Rate limiting
$config->throttles->add('burst',
limit: 30, period: 5,
);
$config->throttles->add('global',
limit: 1000, period: 60,
);
// Add Phirewall LAST (Slim LIFO = executes first)
$app->add(new PhirewallMiddleware($config));
// ... define routes ...
$app->run();// Mezzio uses PSR-15 natively. Pipe Phirewall right after the ErrorHandler.
// In config/autoload/phirewall.global.php:
// return [
// 'dependencies' => [
// 'factories' => [
// \Flowd\Phirewall\Middleware::class =>
// \App\Factory\PhirewallMiddlewareFactory::class,
// ],
// ],
// ];
// src/App/Factory/PhirewallMiddlewareFactory.php:
namespace App\Factory;
use Flowd\Phirewall\Config;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Middleware as PhirewallMiddleware;
use Flowd\Phirewall\Store\ApcuCache;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ServerRequestInterface;
class PhirewallMiddlewareFactory
{
public function __invoke(ContainerInterface $container): PhirewallMiddleware
{
// ApcuCache requires ext-apcu (zero config, single-server)
// For multi-server: use RedisCache with predis/predis
$cache = new ApcuCache();
$config = new Config($cache);
$config->enableRateLimitHeaders();
// Safelists
$config->safelists->add('health',
fn(ServerRequestInterface $req): bool =>
$req->getUri()->getPath() === '/health'
);
$config->safelists->trustedBots(cache: $cache);
// Blocklists
$config->blocklists->knownScanners();
$config->blocklists->suspiciousHeaders();
// Fail2Ban
$config->fail2ban->add('login-abuse',
threshold: 5, period: 300, ban: 3600,
filter: fn(ServerRequestInterface $req): bool =>
$req->getMethod() === 'POST'
&& $req->getUri()->getPath() === '/login',
);
// Rate limiting
$config->throttles->add('burst',
limit: 30, period: 5,
);
$config->throttles->add('global',
limit: 1000, period: 60,
);
$psr17 = new Psr17Factory();
$config->usePsr17Responses($psr17, $psr17);
return new PhirewallMiddleware($config, $psr17);
}
}
// In config/pipeline.php (pipe the ErrorHandler first, then Phirewall):
// $app->pipe(\Laminas\Stratigility\Middleware\ErrorHandler::class);
// $app->pipe(\Flowd\Phirewall\Middleware::class);
// $app->pipe(\Mezzio\Router\Middleware\RouteMiddleware::class);
// $app->pipe(\Mezzio\Router\Middleware\DispatchMiddleware::class);Middleware ordering: Pipe Phirewall as early as possible, but after your error-handling middleware. Phirewall does not wrap the downstream handler, so a handler exception must be able to reach the error handler. See the Examples page for more detailed, production-ready integrations.
Complete Example
Here is a full, runnable example you can copy into a file and execute immediately. Requires nyholm/psr7 (composer require nyholm/psr7):
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Flowd\Phirewall\Config;
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;
// 1. Setup cache
$cache = new InMemoryCache();
// 2. Configure firewall
$config = new Config($cache);
$config->setKeyPrefix('demo');
$config->enableRateLimitHeaders();
// Safelist health endpoint
$config->safelists->add('health', fn($req) => $req->getUri()->getPath() === '/health');
// Block suspicious paths
$config->blocklists->add('scanner-probe', fn($req) => str_starts_with($req->getUri()->getPath(), '/admin-panel'));
// Block known vulnerability scanners
$config->blocklists->knownScanners();
// Rate limit: 10 requests per minute per IP
$config->throttles->add('ip-limit', limit: 10, period: 60);
// Fail2Ban: Ban IPs that POST to /login more than 3 times in 2 minutes
$config->fail2ban->add('login',
threshold: 3,
period: 120,
ban: 300,
filter: fn($req) => $req->getMethod() === 'POST'
&& $req->getUri()->getPath() === '/login',
);
// 3. Create middleware
$middleware = new Middleware($config, new Psr17Factory());
// 4. Your application handler
$handler = new class implements RequestHandlerInterface {
public function handle(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
{
return new Response(200, ['Content-Type' => 'text/plain'], "Hello, World!\n");
}
};
// 5. Process a request
$request = new ServerRequest('GET', '/api/users', [], null, '1.1', ['REMOTE_ADDR' => '192.168.1.100']);
$response = $middleware->process($request, $handler);
echo "Status: " . $response->getStatusCode() . "\n";
// Output: Status: 200Evaluation Order
Phirewall evaluates rules in a strict, deterministic order using an evaluator pipeline. The first match wins.
Request --> Track (passive) --> Safelist --> Blocklist --> Fail2Ban --> Throttle --> Allow2Ban --> Pass
| | | | |
v v v v v
Allow 403 403 429 403The evaluation order is:
- Track rules are always evaluated first (passive counting, never blocks)
- Safelist: if matched, the request bypasses all remaining checks
- Blocklist: if matched, the request is rejected with
403 - Fail2Ban: if the client is already banned,
403; if the filter matches, increment the failure counter - Throttle: if the counter exceeds the limit,
429withRetry-After - Allow2Ban: if the client has exceeded the request threshold,
403withRetry-After - Pass: the request reaches your application
Fail-Open / Fail-Closed
By default, Phirewall fails open: if the cache backend is unavailable or throws an exception, the request is allowed through and the error is dispatched as a FirewallError event for logging.
// Fail-open (default): requests pass through on errors
$config->setFailOpen(true);
// Fail-closed: exceptions propagate, resulting in 500 errors
// Use only when blocking is more important than availability
$config->setFailOpen(false);Response Headers
Diagnostic X-Phirewall headers are opt-in and can be added to blocked or safelisted responses:
$config->enableResponseHeaders();| Header | Opt-in | Description |
|---|---|---|
X-Phirewall | Yes | Block type: blocklist, throttle, fail2ban, or allow2ban |
X-Phirewall-Matched | Yes | Name of the rule that triggered |
X-Phirewall-Safelist | Yes | Name of the safelist rule that matched |
Retry-After | No | Seconds until the client may retry (throttles and allow2ban bans) |
WARNING
Diagnostic headers reveal internal rule names and firewall topology. Only enable them in development or staging environments.
Enable $config->enableRateLimitHeaders() for standard rate limit headers on every response:
| Header | Description |
|---|---|
X-RateLimit-Limit | Configured request limit |
X-RateLimit-Remaining | Remaining requests in window |
X-RateLimit-Reset | Seconds until window resets |
Client IP Behind Proxies
When your application sits behind a load balancer or CDN (Content Delivery Network), REMOTE_ADDR contains the proxy IP, not the client IP. Use TrustedProxyResolver to extract the real client IP safely:
use Flowd\Phirewall\Http\TrustedProxyResolver;
use Flowd\Phirewall\KeyExtractors;
$resolver = new TrustedProxyResolver([
'10.0.0.0/8', // Internal network
'172.16.0.0/12', // Docker
'192.168.0.0/16', // Private ranges
]);
// Use as a key extractor for any rule
$config->throttles->add('api', limit: 100, period: 60,
key: KeyExtractors::clientIp($resolver)
);
// Or set globally so all IP-aware matchers use it
$config->setIpResolver(KeyExtractors::clientIp($resolver));Set a resolver behind a proxy, or every client shares one key
KeyExtractors::ip() reads REMOTE_ADDR verbatim. Behind a CDN or load balancer that value is the proxy's address, so every client collapses onto a single throttle/ban key and your rate limits and bans become useless (or ban everyone at once). The same default applies to file-backed IP blocklists and infrastructure ban listeners. Whenever Phirewall runs behind a proxy, install a client-IP resolver, $config->setIpResolver(KeyExtractors::clientIp(new TrustedProxyResolver([...]))), so rules key on the originating client. And never trust X-Forwarded-For without configuring the trusted proxies: an attacker can otherwise spoof the header to forge any client IP.
Resolver behavior
TrustedProxyResolver walks the forwarded chain from right to left, skipping hops whose address is in your trusted-proxy list, and returns the first untrusted address as the client IP (falling back to REMOTE_ADDR when the chain yields nothing valid). A few details are worth knowing:
// Constructor: trusted proxies first, then the header(s) to consult, then a chain cap.
new TrustedProxyResolver(
trustedProxies: ['10.0.0.0/8', '172.16.0.0/12'],
allowedHeaders: ['X-Forwarded-For'], // default
maxChainEntries: 50, // default
);- The default header is a single header.
allowedHeadersdefaults to['X-Forwarded-For']only. If your stack emits the RFC 7239Forwardedheader instead, pass it explicitly:new TrustedProxyResolver([...], ['Forwarded']), or['Forwarded', 'X-Forwarded-For']for both, so the header the resolver trusts is visible at the call site rather than inferred. - Proxy headers are read only when the direct peer is trusted. The resolver consults
X-Forwarded-For(orForwarded) only whenREMOTE_ADDR, the address that actually connected, is itself in your trusted-proxy list. A request arriving directly from an untrusted client has its forwarded headers ignored and is keyed onREMOTE_ADDR. - All header instances are folded into one chain. Whether intermediaries keep
X-Forwarded-Foras separate lines or fold them into one comma-separated value (the nginx default), the resolver flattens them and walks the chain right to left, returning the first hop that is not in your trusted-proxy list. The protection is this trusted-hop walk, not the number or order of header instances: a client-prepended value sits to the left of the addresses your proxies append, so it is returned only if every hop to its right is trusted. Correct trusted ranges are therefore essential, and stripping or overwriting the inbound header at the edge prevents spoofing outright. - IPv6 is canonicalized. An IPv4-mapped IPv6 peer (
::ffff:203.0.113.7) collapses to its embedded IPv4 form, so a plain IPv4 rule or CIDR matches it and an attacker cannot bypass an IPv4 rule by presenting the mapped form. Alternate genuine-IPv6 spellings (expanded2001:0db8::1vs compressed2001:db8::1, mixed case) are also treated as one identity byip()/ CIDR list matching, which compares the raw binary address. When keys are derived throughKeyExtractors::clientIp(), the resolver canonicalizes the address it returns, so per-client keys stay stable regardless of the spelling the client presents; the consistent-spelling caveat applies only to rawKeyExtractors::ip()(REMOTE_ADDR) or a custom resolver that does not canonicalize.
First Test
With your application served (for the plain-PHP front controller in Step 5, run php -S localhost:8080 public/index.php; otherwise use your framework's own server), verify the firewall by sending requests:
# Should pass (200)
curl -i http://localhost:8080/api/users
# Should be blocked (403) if you have a scanner-probe blocklist
curl -i http://localhost:8080/admin-panel
# Should be safelisted (200, no rate limit counted)
curl -i http://localhost:8080/healthNext Steps
- Learn about Safelists & Blocklists
- Configure Rate Limiting
- Set up Fail2Ban & Allow2Ban for brute force protection
- Explore Storage Backends for production
- Add Observability for monitoring
- Use Request Context for post-handler failure signaling
- Browse Examples for complete, copy-pasteable configurations