Skip to content

Config Composition

Real deployments rarely have a single source of firewall rules. A vendor ships a baseline, an environment (staging vs. production) adds its own rules, a tenant overrides a few, and a single deployment applies a last-minute tweak. Config::with() applies these layers into one effective Config (without mutating any input) so each layer can be owned, versioned, and shipped independently. A layer is any ConfigLayer - a live Config or a PortableConfig.

Usage

php
use Flowd\Phirewall\Config;

// Each layer is owned and versioned independently, usually as a PortableConfig.
// Apply them onto your cache with Config::with(); later layers win.
// The cache lives only on Config; the portable layers never carry one.
$effective = (new Config($cache))->with(
    $vendorPortable,        // shared product defaults
    $environmentPortable,   // staging vs. production
    $tenantPortable,        // per-customer policy
);

// A Config is itself a ConfigLayer, so configs apply directly through the same call
// (same precedence; later layers win):
$effective = $base->with($vendorConfig, $environmentConfig, $tenantConfig);

with() is the one instance method for composition: it takes variadic ConfigLayers and returns a fresh Config; the base and every overlay are left untouched.

Merge semantics

Overlays are applied left to right, so later sources win.

AspectRule
Rules (safelists, blocklists, throttles, fail2ban, allow2ban, tracks)Merged by name within each section. A later same-named rule replaces the earlier one in place (base ordering preserved); genuinely new names are appended. A union, never duplicates.
Pattern backendsMerged by name with the same later-wins rule.
enabledLast layer wins (fail-safe): the composed value is the enabled state of the highest-priority (last) layer. An explicit enable() / disable() / setEnabled() on the winning layer always takes effect, so an ambiguous composition is never left silently disabled.
Other scalar / object options (keyPrefix, failOpen, the response-header toggles, the IP resolver, the discriminator normalizer, the response factories)Last explicit value wins: the value comes from the last layer whose value differs from the field default. A layer that left an option at its default never clobbers an explicit choice from an earlier layer.
Infrastructure (PSR-16 cache, PSR-14 event dispatcher, clock)Inherited from the base layer; overlays do not override it.

Why "last explicit value wins"?

A Config does not track which options were set versus left at their default. Composition therefore treats "still at the field default" as "no opinion": only a value that differs from the default counts as an explicit choice that can override an earlier layer. This is what lets a thin overlay add a single rule without silently resetting the baseline's keyPrefix or failOpen policy back to the defaults.

Limitation: an overlay cannot reset a toggle to its default

Because "default-valued" is read as "no opinion", an overlay cannot turn a toggle back off once an earlier layer turned it on. If the vendor baseline calls enableResponseHeaders() (changing the toggle from its false default to true), a tenant overlay that leaves the toggle at false will not switch it back off; its false is indistinguishable from "unspecified", so the baseline's explicit true wins. The same applies to failOpen and the other boolean toggles. (enabled is the deliberate exception: as its row above notes, it uses last-layer-wins, so a later layer can re-assert it.)

If you need a later layer to force a non-default option back to the default, do not rely on composition: build the final Config and set the option explicitly after composing, e.g. (new Config($cache))->with(...)->setFailOpen(true).

IP resolver: autowired matchers compose, an explicit resolver is fixed

IP-aware matchers (IpMatcher, the file/snapshot IP blocklists, TrustedBotMatcher) autowire the client-IP resolver. Constructed without an explicit resolver, they resolve the client IP through the Config they run under, at request time. So a composed Config applies its own merged IP resolver to these matchers no matter which layer defined them, the same way keyless counter rules (throttle, fail2ban, allow2ban, track) resolve their default IP key against the Config they run under.

The exception is a matcher given an explicit resolver in its constructor: it keeps that resolver and ignores the composed Config's. Composition copies already-built rule objects, so it cannot rewrite a resolver baked into a matcher. If you want every layer's IP rules to follow one resolver, leave the matchers' resolver unset and set it once on the final Config with setIpResolver(); reserve an explicit per-matcher resolver for the rare rule that must resolve the client IP differently from the rest.

Example

php
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Http\Firewall;

$effective = $vendorBaseline->with($environmentOverlay, $tenantOverlay, $deploymentTweak);

// Rules unioned by name, base ordering preserved:
$effective->blocklists->rules();   // ['scanners' (tenant wins), 'bad-net', 'secrets-probe', ...]
$effective->allow2ban->rules();    // ['volume-cap'] contributed by the tenant overlay

// Last-explicit-wins options:
$effective->getKeyPrefix();          // 'deploy-eu-1'  (last layer that set it)
$effective->isFailOpen();            // false          (only the deployment layer set it)
$effective->responseHeadersEnabled(); // true          (set by the environment overlay)

$firewall = new Firewall($effective);

See examples/30-config-composition.php for a full vendor → environment → tenant → deployment walkthrough that prints an overridden-by-name rule, the unioned rule sets, and the last-wins options, then proves the composed firewall enforces every layer while leaving the inputs unchanged.