flowd / phirewall
A PHP Firewall and rate limiter based on PSR-7 and PSR-15 middleware (safelists, blocklists, throttles, fail2ban)
Requires
- php: >=8.2
- psr/event-dispatcher: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/simple-cache: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.89
- infection/infection: ^0.29
- mikey179/vfsstream: ^1.6
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^11.5
- rector/rector: ^1.2
Suggests
- ext-apcu: Optional: use ApcuCache for fast in-process counters (enable with apc.enable_cli=1 for CLI)
- predis/predis: Optional: use RedisCache for distributed counters
This package is auto-updated.
Last update: 2026-06-16 21:10:05 UTC
README
Protect your PHP application from brute force, DDoS, SQL injection, XSS, and bot attacks with a single middleware.
Phirewall is a PSR-15 middleware that provides comprehensive application-layer protection. It's lightweight, framework-agnostic, and easy to configure.
Why Phirewall?
- Simple Setup - Add protection in minutes with sensible defaults
- Multiple Attack Vectors - Rate limiting, brute force protection, bot detection, and OWASP CRS (the latter via a companion package)
- Framework Agnostic - Works with any PSR-15 compatible framework (Laravel, Symfony, Slim, Mezzio, etc.)
- Production Ready - Redis support for multi-server deployments
- Observable - PSR-14 events for logging, metrics, and alerting
Quick Start
composer require flowd/phirewall
use Flowd\Phirewall\Config; use Flowd\Phirewall\Middleware; use Flowd\Phirewall\KeyExtractors; use Flowd\Phirewall\Store\InMemoryCache; // Create the firewall $config = new Config(new InMemoryCache()); // Allow health checks to bypass all rules $config->safelists->add('health', fn($req) => $req->getUri()->getPath() === '/health'); // Block common scanner paths $config->blocklists->add('scanners', fn($req) => str_starts_with($req->getUri()->getPath(), '/wp-admin')); // Rate limit: 100 requests per minute per IP $config->throttles->add('api', limit: 100, period: 60 /* seconds */, key: KeyExtractors::ip()); // Ban IP after 5 failed logins in 5 minutes. The filter never matches at // request time — failures are signaled from the handler via RequestContext // (see "Login Protection" below for the handler-side snippet). $config->fail2ban->add('login', threshold: 5, period: 300 /* seconds */, ban: 3600 /* seconds */, filter: fn($req) => false, key: KeyExtractors::ip() ); // Add to your middleware stack $middleware = new Middleware($config); // The PSR-17 ResponseFactory is optional — Phirewall auto-detects installed factories. // Pass one explicitly if needed: new Middleware($config, new Psr17Factory())
Add the middleware to your PSR-15 pipeline. All requests will be evaluated against your rules before reaching your application.
Try It Now
Run one of the included examples to see Phirewall in action:
# Basic setup demo php examples/01-basic-setup.php # See brute force protection php examples/02-brute-force-protection.php # See scanner and bot detection php examples/06-bot-detection.php # Full production setup php examples/08-comprehensive-protection.php
Examples
The examples/ folder contains runnable examples:
| # | Example | Description |
|---|---|---|
| 01 | basic-setup | Minimal configuration to get started |
| 02 | brute-force-protection | Fail2Ban-style login protection |
| 03 | api-rate-limiting | Tiered rate limits for APIs |
| 06 | bot-detection | Scanner and malicious bot blocking |
| 07 | ip-blocklist | File-backed IP/CIDR blocklists |
| 08 | comprehensive-protection | Production-ready multi-layer setup |
| 09 | observability-monolog | Event logging with Monolog |
| 10 | observability-opentelemetry | Distributed tracing with OpenTelemetry |
| 11 | redis-storage | Redis backend for multi-server deployments |
| 12 | apache-htaccess | Apache .htaccess IP blocking |
| 13 | benchmarks | Storage backend performance comparison |
| 15 | in-memory-pattern-backend | Configuration-based CIDR/IP blocklists |
| 16 | allow2ban | Hard volume cap with auto-ban |
| 17 | known-scanners | Block known attack tools and vulnerability scanners |
| 18 | trusted-bots | Trusted bot verification via reverse DNS |
| 19 | header-analysis | Suspicious headers detection |
| 20 | rule-benchmarks | Firewall rule performance benchmarks |
| 21 | sliding-window | Sliding window rate limiting |
| 22 | multi-throttle | Multi-window burst + sustained rate limiting |
| 23 | dynamic-limits | Role-based dynamic throttle limits |
| 24 | pdo-storage | PdoCache with SQLite, MySQL, PostgreSQL |
| 25 | track-threshold | Track with optional threshold and thresholdReached flag |
| 26 | psr17-factories | PSR-17 response factory integration |
| 27 | request-context | RequestContext API for post-handler fail2ban signaling |
| 28 | portable-config-signing | HMAC-signed PortableConfig transport with tamper rejection |
| 29 | portable-config | PortableConfig as data: round-trip, signing, and DB hot-reload |
| 30 | config-composition | Layer vendor + environment + tenant + deployment Configs into one |
| 31 | presets | Ready-to-use rule bundles: standalone use, portable inspection, composition, and version checks |
Features
Protection Layers
| Feature | Description |
|---|---|
| Safelists | Bypass all checks for trusted requests (health checks, internal IPs) |
| Blocklists | Immediately deny suspicious requests (403) |
| Throttling | Fixed and sliding window rate limiting by IP, user, API key, or custom key (429) with dynamic limits and multiThrottle |
| Fail2Ban | Auto-ban after repeated failures |
| Allow2Ban | Hard volume cap -- ban after too many total requests |
| Track with Threshold | Passive counting with optional alert threshold |
| OWASP CRS | SQL injection, XSS, and more via the companion package flowd/phirewall-preset-owasp-crs |
| Pattern Backends | File/Redis-backed blocklists with IP, CIDR, path, and header patterns |
Matchers
| Matcher | Description |
|---|---|
| Known Scanners | Block sqlmap, nikto, nmap, and other scanner User-Agents |
| Trusted Bots | Safelist Googlebot, Bingbot, etc. via reverse DNS verification |
| Suspicious Headers | Block requests missing standard browser headers |
| IP Matcher | Safelist or block by IP/CIDR range |
Observability
- PSR-14 Events -
SafelistMatched,BlocklistMatched,ThrottleExceeded,Fail2BanBanned,Allow2BanBanned,TrackHit,FirewallError - Fail-Open by Default - Cache outages don't take down the application; a
FirewallErrorevent is dispatched via PSR-14. Trade-off: while failing open all rules are skipped, so deployments that must keep blocking during an outage shouldsetFailOpen(false)and monitorFirewallError - Diagnostics Counters - Per-rule statistics for monitoring
- Standard Headers -
X-RateLimit-*,Retry-After,X-Phirewall-*
Storage Backends
| Backend | Use Case |
|---|---|
InMemoryCache |
Development, testing, single requests |
ApcuCache |
Single-server production |
RedisCache |
Multi-server production |
PdoCache |
SQL-backed persistence (MySQL, PostgreSQL, SQLite) |
All backends are PSR-16 caches and validate keys accordingly: a key must be a non-empty string with none of the PSR-16 reserved characters ({}()/\@:). As an additional restriction of its own (beyond PSR-16), Phirewall also rejects control and whitespace characters, and the multi-key methods reject non-string keys. Invalid keys raise Flowd\Phirewall\Store\InvalidCacheKeyException (a Psr\SimpleCache\InvalidArgumentException). Phirewall's own keys are always compliant.
Documentation
Full documentation is available at phirewall.de:
- Getting Started - Installation & quick start guide
- Framework Integration - PSR-15, Laravel, Symfony, Slim, Mezzio
- Features - Safelists, blocklists, rate limiting, fail2ban, bot detection, OWASP rules
- Advanced - Dynamic throttles, observability, infrastructure adapters
- Common Attacks - Protection recipes for 10+ attack types
- FAQ - Frequently asked questions
Installation
composer require flowd/phirewall
Optional Dependencies
# For Redis-backed distributed counters (multi-server) composer require predis/predis # For Monolog logging integration composer require monolog/monolog
APCu: Enable the PHP extension and set apc.enable_cli=1 for CLI testing.
Response Headers
Phirewall can add diagnostic headers to the response when a request is blocked or safelisted. These diagnostic headers are opt-in and disabled by default:
$config->enableResponseHeaders(); // Enable X-Phirewall, X-Phirewall-Matched, and X-Phirewall-Safelist headers
| Header | Description | Opt-in required |
|---|---|---|
X-Phirewall |
Block type: blocklist, throttle, fail2ban, allow2ban |
Yes |
X-Phirewall-Matched |
Rule name that triggered | Yes |
X-Phirewall-Safelist |
Safelist rule that matched (on allowed requests) | Yes |
Retry-After |
Seconds until the client may retry (throttles and allow2ban bans) | No (always present) |
Note:
Retry-Afteris always included on responses where a retry delay applies (429 throttles and allow2ban bans), regardless ofenableResponseHeaders().
Enable $config->enableRateLimitHeaders() for standard X-RateLimit-* headers.
Client IP Behind Proxies
When behind load balancers or CDNs, use TrustedProxyResolver:
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 ]); $config->throttles->add('api', limit: 100, period: 60, key: KeyExtractors::clientIp($resolver) );
Custom Responses
Customize blocked responses while keeping standard headers:
use Flowd\Phirewall\Config\Response\ClosureBlocklistedResponseFactory; use Flowd\Phirewall\Config\Response\ClosureThrottledResponseFactory; $config->blocklistedResponseFactory = new ClosureBlocklistedResponseFactory( function (string $rule, string $type, $req) { return new Response(403, ['Content-Type' => 'application/json'], json_encode(['error' => 'Blocked', 'rule' => $rule]) ); } ); $config->throttledResponseFactory = new ClosureThrottledResponseFactory( function (string $rule, int $retryAfter, $req) { return new Response(429, ['Content-Type' => 'application/json'], json_encode(['error' => 'Rate limited', 'retry_after' => $retryAfter]) ); } );
PSR-17 Response Factories
Use standard PSR-17 factories for framework-native responses:
use Nyholm\Psr7\Factory\Psr17Factory; $psr17 = new Psr17Factory(); $config->usePsr17Responses($psr17, $psr17);
Or customise body text per response type:
use Flowd\Phirewall\Config\Response\Psr17BlocklistedResponseFactory; use Flowd\Phirewall\Config\Response\Psr17ThrottledResponseFactory; $config->blocklistedResponseFactory = new Psr17BlocklistedResponseFactory( $psr17, $psr17, 'Access Denied', ); $config->throttledResponseFactory = new Psr17ThrottledResponseFactory( $psr17, $psr17, 'Rate limit exceeded.', );
OWASP Core Rule Set
OWASP CRS detection (SQL injection, XSS, RCE, LFI, ...) lives in the companion package flowd/phirewall-preset-owasp-crs. It ships the ModSecurity SecRule engine plus ready-made config-set presets:
composer require flowd/phirewall-preset-owasp-crs
use Flowd\PhirewallPresetOwaspCrs\ParanoiaLevel; use Flowd\PhirewallPresetOwaspCrs\Presets; $config = $config->with(Presets::blocklist(ParanoiaLevel::Level1));
The SecRule engine itself (Flowd\PhirewallPresetOwaspCrs\Engine\SecRuleLoader) is part of that
package too, so you can also load your own ModSecurity-style .conf rules. The
engine was extracted from this core package in 0.6.
Portable Config
PortableConfig expresses a ruleset as plain, JSON-serializable data instead of PHP closures, so a configuration can be stored in a database, shipped through a config service, diffed in git, or shared between processes — then rebuilt into a live Config with Config::with() (a PortableConfig is a ConfigLayer).
use Flowd\Phirewall\Config; use Flowd\Phirewall\Pattern\PatternKind; use Flowd\Phirewall\Portable\PortableConfig; $portable = PortableConfig::create() ->setKeyPrefix('shop') ->enableResponseHeaders() ->safelist('health', PortableConfig::filterPathEquals('/health')) ->blocklist('admin-probe', PortableConfig::filterPathPrefix('/wp-admin')) ->blocklist('scanners', PortableConfig::filterKnownScanners()) ->blocklist('bad-net', PortableConfig::filterIp(['203.0.113.0/24'])) ->throttle('api', limit: 100, period: 60, key: PortableConfig::keyHashedHeader('X-Api-Key'), sliding: true) ->allow2ban('volume-cap', threshold: 1000, period: 60, ban: 300, key: PortableConfig::keyIp()) ->fail2ban('login', threshold: 5, period: 60, ban: 900, filter: PortableConfig::filterHeaderEquals('X-Login-Failed', '1'), key: PortableConfig::keyIp()) ->patternBlocklist('threats', [ PortableConfig::patternEntry(PatternKind::CIDR, '10.66.0.0/16'), PortableConfig::patternEntry(PatternKind::PATH_REGEX, '#/\.git(/|$)#'), ]); // Round-trip as data … $array = $portable->toArray(); $config = (new Config($cache))->with(PortableConfig::fromArray($array));
Supported rule types: safelists, blocklists, throttles (incl. sliding and an optional scope filter that restricts which requests the throttle counts — e.g. filterPathPrefix('/api')), fail2ban, allow2ban, tracks, and pattern backends. Filters: all, none, path_equals, path_prefix, path_regex, method_equals, method_in, header_equals, header_present, header_regex, plus the matcher-backed ip, known_scanners, and suspicious_headers. Key extractors: ip, method, path, header, hashed_header.
Signed transport
When the serialized config is read back from storage you do not fully control (a shared filesystem, S3, etcd, a config service), sign it so tampering — e.g. an injected allow-all safelist — is rejected before the rules are applied:
$signed = $portable->toSignedJson($secretKey); // HMAC-SHA256 envelope $restored = PortableConfig::loadSigned($signed, $secretKey); // throws on tamper / wrong key
Signing keys must be at least 16 bytes (32 random bytes recommended). See 28-portable-config-signing.php and 29-portable-config.php (round-trip, signing, and a database hot-reload scenario).
Tip: Stack several PortableConfigs (vendor baseline, environment, tenant, …) into one effective ruleset with Config composition / layering.
Not portable by design: trusted-bot reverse-DNS matchers, OWASP CRS rulesets, file-backed lists, and closure-driven dynamic throttle limits are not serializable and are intentionally excluded from the schema.
Config composition / layering
Real deployments rarely have a single source of firewall rules. A vendor ships a baseline, an environment adds its own rules, a tenant overrides a few, and a single deployment applies a last-minute tweak. Config::with(ConfigLayer ...$layers) applies these layers onto one effective Config — without mutating any input — so each layer can be owned and shipped independently. A layer is anything that implements ConfigLayer: another Config, or a PortableConfig (rules as data).
use Flowd\Phirewall\Config; // Each layer is a ConfigLayer — frequently a PortableConfig — applied onto one base Config. $layered = (new Config($cache))->with( $vendorPortable, // shared product defaults $envPortable, // staging vs. production $tenantPortable, // per-customer policy ); $deploymentTweak = (new Config($cache))->setFailOpen(false); // Later layers win. $effective = $layered->with($deploymentTweak);
Merge semantics (overlays applied left to right, so later sources win):
- Rules merge by name within each section (safelists, blocklists, throttles, fail2ban, allow2ban, tracks). When the same rule name appears in more than one layer the later rule replaces the earlier one in place — base ordering is preserved and genuinely new rules are appended. The result is a union, never duplicates.
- Pattern backends (behind pattern blocklists) merge by name the same way.
enableduses strict last-layer-wins (fail-safe): the composed value is the last layer'senabled, so an explicitenable()/disable()always takes effect and an ambiguous composition is never left silently disabled — the one exception to "last explicit value wins".- Other scalar / object options (
keyPrefix,failOpen, the response-header toggles, the IP resolver, the discriminator normalizer, the response factories) follow last explicit value wins: the value comes from the last layer whose value differs from the field default, so a layer that simply left an option alone never clobbers an explicit choice from an earlier layer. The IP resolver also reaches rules at evaluation time: IP-aware matchers and keyless counter rules added without an explicit resolver resolve the client IP against the composedConfig, so a later layer's resolver applies to rules carried over from earlier layers. Only a matcher given an explicit resolver keeps it regardless of layering. - Infrastructure — the PSR-16 cache, PSR-14 event dispatcher, and clock — is inherited from the base layer.
See 30-config-composition.php for a full vendor → environment → tenant → deployment walkthrough.
Presets
Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a PortableConfig: plain, inspectable, serializable data, and a ConfigLayer, returned directly (to serialize, diff, sign, or layer) and applied onto a Config with Config::with().
use Flowd\Phirewall\Config; use Flowd\Phirewall\Preset\Presets; // A preset on its own (a Config requires a PSR-16 cache): $config = (new Config($cache))->with(Presets::scannerBlocking()); // Inspect / serialize the underlying portable schema: $schema = Presets::scannerBlocking()->toArray(); // Presets are layers, so they apply onto your own base Config (later wins by name): $config = (new Config($cache))->with( Presets::scannerBlocking(), Presets::sensitivePathBlocking(), $myConfig, // your overrides win );
| Preset | Rules (all namespaced preset.<area>.*) |
|---|---|
scannerBlocking() |
preset.scanner.known-tools (known scanner/exploit User-Agents) + preset.scanner.suspicious-headers (missing standard browser Accept-* headers). |
sensitivePathBlocking() |
preset.sensitive-path.probes: pattern blocklist for /.git, /.svn, /.hg, /.env*, /.aws/credentials, /.htpasswd, /.htaccess, /.DS_Store. |
Conventions & overrides. The shipped presets target signals that are universal across applications (scanner User-Agents, missing browser headers, well-known sensitive paths), so they assume nothing about your routing; a PortableConfig you build yourself can key on whatever fits your environment, including routes your own apps standardize. Because every rule is namespaced, you override any of them by composing the preset with your own Config that redefines the rule by the same name.
Note:
scannerBlocking()'ssuspicious-headersrule is aggressive: some legitimate API clients and privacy tools also omitAccept-*headers. Drop or override it by name if your traffic includes non-browser clients.
Versioning & update checks. Presets::VERSION identifies the bundled rule catalogue. To surface "a newer ruleset is available", compare Presets::VERSION against a feed you trust (Packagist, an internal config service, a versioned JSON document, …) with version_compare(Presets::VERSION, $latestFromYourFeed, '<'). Phirewall hardcodes no endpoint and performs no network I/O; wiring a real source is the integrator's job.
See 31-presets.php for standalone use, portable inspection, composition with override-by-name, and the version comparison.
Real-World Recipes
API Rate Limiting
use Flowd\Phirewall\KeyExtractors; // Global limit $config->throttles->add('global', limit: 1000, period: 60, key: KeyExtractors::ip()); // Burst + sustained rate limiting with multiThrottle $config->throttles->multi('api', [ 1 => 5, // 5 req/s burst 60 => 200, // 200 req/min sustained ], KeyExtractors::ip()); // Dynamic limits based on user role // Note: a header-keyed rule is skipped when the header is absent, so a client can avoid the // limit by omitting X-User-Id. Pair it with a rule that rejects the missing header, or key on IP. $config->throttles->add('user', fn($req) => $req->getHeaderLine('X-Plan') === 'pro' ? 5000 : 100, 60, KeyExtractors::header('X-User-Id') );
Login Protection
use Flowd\Phirewall\KeyExtractors; // Throttle login attempts $config->throttles->add('login', limit: 10, period: 60, key: function($req) { return $req->getUri()->getPath() === '/login' ? $req->getServerParams()['REMOTE_ADDR'] : null; }); // Ban after failures — signaled via RequestContext from your handler $config->fail2ban->add('login-ban', threshold: 5, period: 300, ban: 3600, filter: fn($request): bool => false, key: KeyExtractors::ip() );
In your login handler, signal failures via the request context. The second
argument is optional — when omitted, the firewall reuses the rule's own
keyExtractor against the current request:
use Flowd\Phirewall\Context\RequestContext; $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); if (!$authenticated && $context instanceof RequestContext) { $context->recordFailure('login-ban'); }
Bot Detection
$scanners = ['sqlmap', 'nikto', 'nmap', 'burp', 'dirbuster']; $config->blocklists->add('scanners', function($req) use ($scanners) { $ua = strtolower($req->getHeaderLine('User-Agent')); foreach ($scanners as $scanner) { if (str_contains($ua, $scanner)) return true; } return false; });
Development
# Run tests composer test # Run PdoCache tests against SQLite, MySQL, and PostgreSQL (requires Docker) composer test:database # Or directly: ./bin/test-databases.sh --keep (keeps containers running) # Run performance benchmarks only (no coverage, Xdebug disabled) XDEBUG_MODE=off PHIREWALL_RUN_BENCHMARKS=1 vendor/bin/phpunit --group performance --no-coverage # Fix code style composer fix # Mutation testing composer test:mutation
Sponsors
This project received funding from TYPO3 Association through its Community Budget program.
License
Dual licensed under LGPL-3.0-or-later and proprietary. See LICENSE for details.