flowd/phirewall

A PHP Firewall and rate limiter based on PSR-7 and PSR-15 middleware (safelists, blocklists, throttles, fail2ban)

Maintainers

Package info

github.com/flowd/phirewall

pkg:composer/flowd/phirewall

Statistics

Installs: 2 916

Dependents: 1

Suggesters: 0

Stars: 16

Open Issues: 3

0.4.0 2026-05-19 12:59 UTC

README

Phirewall Logo

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-After is always included on responses where a retry delay applies (429 throttles and allow2ban bans), regardless of enableResponseHeaders().

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 Configwithout 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.
  • enabled uses strict last-layer-wins (fail-safe): the composed value is the last layer's enabled, so an explicit enable() / 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()'s suspicious-headers rule is aggressive — some legitimate API clients and privacy tools also omit Accept-* 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.

Read more

License

Dual licensed under LGPL-3.0-or-later and proprietary. See LICENSE for details.