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 not auto-updated.
Last update: 2026-06-02 13:06:28 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, OWASP rules, bot detection
- 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 # Test SQL injection blocking php examples/04-sql-injection-blocking.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 |
| 04 | sql-injection-blocking | OWASP-style SQLi detection |
| 05 | xss-prevention | Cross-Site Scripting protection |
| 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 |
| 14 | owasp-crs-files | Loading OWASP CRS rules from files |
| 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 |
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 PHP injection detection |
| 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; errors dispatched via PSR-14
- 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
Load OWASP-style rules for SQL injection, XSS, and more:
use Flowd\Phirewall\Owasp\SecRuleLoader; $rules = SecRuleLoader::fromString(<<<'CRS' SecRule ARGS "@rx (?i)\bunion\b.*\bselect\b" "id:942100,phase:2,deny,msg:'SQLi'" SecRule ARGS "@rx (?i)<script[^>]*>" "id:941100,phase:2,deny,msg:'XSS'" CRS); $config->blocklists->owasp('owasp', $rules);
Or load from files:
$rules = \Flowd\Phirewall\Owasp\SecRuleLoader::fromDirectory('/path/to/crs-rules');
See 04-sql-injection-blocking.php and 05-xss-prevention.php for complete examples.
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::combine().
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))->combine(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::compose() (and the fluent $base->mergedWith(...)) merges these layers into one effective Config — without mutating any input — so each layer can be owned and shipped independently (often as a PortableConfig).
use Flowd\Phirewall\Config; // Each layer is data — frequently a PortableConfig — combined onto one base Config. $layered = (new Config($cache))->combine( $vendorPortable, // shared product defaults $envPortable, // staging vs. production $tenantPortable, // per-customer policy ); $deploymentTweak = (new Config($cache))->setFailOpen(false); // Later layers win. Equivalent: Config::compose($layered, $deploymentTweak). $effective = $layered->mergedWith($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. Note: IP-aware matchers capture their resolver when the rule is built, so composing a different IP resolver does not rewrite IP rules carried over from earlier layers. - 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 — returned directly (to serialize, diff, sign, or layer) and materialized onto a Config with Config::combine().
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))->combine(Presets::apiRateLimiting()); // Inspect / serialize the underlying portable schema: $schema = Presets::apiRateLimiting()->toArray(); // Because presets ARE PortableConfigs, they combine onto your own base Config (later wins by name): $config = (new Config($cache))->combine(Presets::loginProtection())->mergedWith($myConfig); $config = (new Config($cache))->combine( Presets::scannerBlocking(), Presets::sensitivePathBlocking(), Presets::apiRateLimiting(), )->mergedWith($myConfig); // your overrides win
| Preset | Rules (all namespaced preset.<area>.*) |
|---|---|
apiRateLimiting() |
Per-client sliding-window throttles on the /api prefix: preset.api.burst (20 req/s) and preset.api.sustained (300 req/60s), keyed on client IP. |
loginProtection() |
preset.login.throttle (10 attempts/60s per IP on /login) + preset.login.bruteforce fail2ban (ban the IP for 15 min after 5 failures in 15 min). |
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. apiRateLimiting() scopes to /api and loginProtection() to /login; the login fail2ban counts a failure only when your handler calls $context->recordFailure(Presets::LOGIN_FAILURE_RULE) after a failed authentication. The rule uses a never-match filter on purpose — counting failures from a spoofable marker header would let an attacker forge it to ban an arbitrary IP (and behind a shared proxy/CDN, ban the proxy itself and lock out everyone). 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. IP-keyed rules use REMOTE_ADDR; behind a proxy/CDN, layer your own throttle keyed on a trusted client IP (see Client IP Behind Proxies).
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", implement the PresetUpdateChecker interface against a source you trust (Packagist, an internal config service, a versioned JSON document, …) and compare against Presets::VERSION. Phirewall hardcodes no endpoint and performs no network I/O: the shipped NullPresetUpdateChecker never reports an update, and 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 update-check seam.
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 $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.