funnypot-laravel¶
metrictower/funnypot-laravel is the Laravel adapter for funnypot: a thin service-provider + middleware
layer over funnypot-policy, the position-blind decision engine. It normalises
an incoming Illuminate\Http\Request, asks the policy engine's PolicyEngine for a Decision, and
executes it — allow, log, block, or deceive. The package owns no decision logic itself; the same
decision matrix runs identically here, in the WordPress adapter, and in the standalone app.
Reach for this package when you're protecting a Laravel application (8 through 12) and want either an automatic 404 → believable-fake upgrade, a before-the-router detection pass, or just a one-line detection call for an app that already owns its own response.
Install¶
- PHP: 8.0 or newer
- Laravel:
illuminate/*8.0 – 12.0 - The service provider (
Funnypot\Laravel\FunnypotServiceProvider) is auto-discovered — nothing to register by hand. - Installing this package pulls in the three siblings it adapts: funnypot-core, funnypot-policy, and funnypot-mainnet-client.
Publish the config file:
Add a funnypot log channel to config/logging.php so enforcement/telemetry logging has somewhere to go:
'funnypot' => ['driver' => 'single', 'path' => storage_path('logs/funnypot.log'), 'level' => 'debug'],
Usage¶
The package never forces itself into your app — you opt in per position.
NOT_FOUND position — upgrade your 404s¶
Add the responder as your fallback route, after your real routes, in routes/web.php:
Every unmatched path (a scanner probing /wp-login.php, /.env, …) is now upgraded from a plain 404 to
a believable fake — false-positive-free by construction, since the counterfactual was already a 404.
BEFORE position — evaluate real routes¶
Push the funnypot middleware onto a group or the global stack. In bootstrap/app.php (Laravel 11+):
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('web', \Funnypot\Laravel\HoneypotMiddleware::class);
// or: $middleware->alias(['funnypot' => \Funnypot\Laravel\HoneypotMiddleware::class]);
})
Positions are a config choice
Which position(s) the engine actually evaluates is controlled by the posture config key, not by
which of the snippets above you add — see Configuration below.
Detection only — for apps that own their response¶
If your app already has its own 404 handler or response pipeline, call detection directly through the facade instead of installing either position:
handleRequest() returns funnypot's byte-exact fake (deceive) or an honest block for a probe, and
null when the request looks clean — in which case you serve your own response. Always return the
result in Laravel; only pass $die = true from a raw-PHP entry point with no framework to return into.
For more control, use inspectRequest() and act on the result yourself:
$result = Funnypot::inspectRequest($request);
if ($result->isSuspicious()) {
return $result->toResponse();
}
// $result->action(), $result->reason(), $result->decision() are available too.
The facade is caller-decides
Funnypot::handleRequest() / toResponse() are the enforce action — they always serve the
fake/block when the underlying decision calls for it. They are not gated by the enforcement
config, which only governs the installed HoneypotMiddleware and FallbackResponder. For an
observe-only integration, call inspectRequest() and act on isSuspicious() without calling
toResponse().
A clean/allow verdict (or a detection fault) comes back as isClean() — treat that as "no opinion,"
never as proof the request is safe.
Enforcement modes¶
Independently of which position is wired in, enforcement decides — per position — whether the adapter
performs funnypot's decision or only watches it. Modes are the string constants on
Funnypot\Laravel\Enforcement:
| Mode | Constant | Behaviour |
|---|---|---|
enforce |
Enforcement::ENFORCE |
Serve the fake or the block. |
observe |
Enforcement::OBSERVE |
Detect + report + log the withheld action, then pass through — your app owns the response. |
off |
Enforcement::OFF |
Never evaluate (a per-position kill switch). |
'enforcement' => [
'before' => Funnypot\Laravel\Enforcement::OBSERVE, // default: watch, never block on install
'not_found' => Funnypot\Laravel\Enforcement::ENFORCE, // default: deceive 404s
],
Defaults are safe-by-default: a fresh install only watches + logs on the before position and never
silently starts blocking real traffic. Reporting fires in every mode when the request was judged
malicious — observe withholds only the response, never the report.
Configuration¶
config/funnypot.php is a Laravel front-end that produces the policy engine's config array. Highlights:
| Key | Purpose |
|---|---|
posture |
honeypot (default, deceive on 404) · WAF (block on the before position) · both. Selects which position(s) the engine evaluates. |
enforcement.before / enforcement.not_found |
Per position: enforce | observe | off — see above. |
response_style |
realistic (default) | minimal | taunt — how a fake looks. |
mainnet.base_url / mainnet.key |
The reputation/report service address + your operator-issued key. An empty key makes reporting, the reputation check, and mirror sync all inert. |
check.enabled |
Off by default — the opt-in reputation gate (spends credits, sends the visitor IP to a third party). |
mirror.enabled |
The local reputation mirror, kept warm by funnypot:mirror-sync. |
reporting.self_ips |
Your own egress/test IPs — list these before enabling reporting from a host that also runs scans. |
state.cache_store |
The Laravel cache store backing local state. Use a persistent, multi-node-safe store (redis / database / memcached) in production, not a per-node file. |
The published file is fully commented — treat it as the reference for every key.
Artisan commands¶
| Command | Purpose |
|---|---|
funnypot:rules-update |
Fetch, verify, and hot-swap a signed rules release. |
funnypot:update <templates> |
Recompile the template index. |
funnypot:mirror-sync |
Pull the reputation mirror artifact from the mainnet service. |
funnypot:report-drain |
Deliver report rows parked when the queue connection is synchronous. |
Typically scheduled in routes/console.php:
Schedule::command('funnypot:mirror-sync')->hourly();
Schedule::command('funnypot:report-drain')->everyFiveMinutes();
Schedule::command('funnypot:rules-update')->daily();
Public API¶
Everything a consumer calls lives under the Funnypot\Laravel\ namespace.
| Class / facade | Members | Purpose |
|---|---|---|
Funnypot\Laravel\Facades\Funnypot |
handleRequest(Request $request, bool $die = false): ?Response, inspectRequest(Request $request): InspectionResult, inspect(Request $request): ?Decision |
The detection facade for apps that own their own response. |
Funnypot\Laravel\InspectionResult |
isSuspicious(): bool, isClean(): bool, action(): string, reason(): string, decision(): ?Decision, toResponse(): ?Response |
Result DTO from inspectRequest(). |
Funnypot\Laravel\HoneypotMiddleware |
handle(Request $request, Closure $next) |
The funnypot middleware alias — the BEFORE position. |
Funnypot\Laravel\FallbackResponder |
handle(Request $request): Response |
Wired as Route::fallback() — the NOT_FOUND position. |
Funnypot\Laravel\Enforcement |
OFF, OBSERVE, ENFORCE constants; values(), isValid(string $v), normalize($value) |
Per-position enforcement-mode constants used in config. |
Funnypot\Laravel\FunnypotServiceProvider |
— | Auto-discovered; merges config, binds the policy engine + its ports, registers the middleware alias and Artisan commands. |
InspectionResult::decision() and Funnypot::inspect() return a Funnypot\Policy\Decision from the
underlying funnypot-policy engine.
Safety invariants
- The engine only ever upgrades a 404 — any mapper/port/evaluate fault degrades to pass-through (or your app's own 404), never a 500.
- The response mapper copies the fake's status, Content-Type, and headers verbatim; status is always app/engine-chosen, never model-chosen.
- The reputation check fails open and is never a synchronous network call on the request path.
Links¶
- Repository: github.com/metrictower/funnypot-laravel
- Packagist: packagist.org/packages/metrictower/funnypot-laravel