Report bot and AI-crawler traffic to WebDecoy from any PHP application.
PHP runs roughly 70% of the server-side web, and WordPress is about 58% of that. Everything else (Laravel, Symfony, Magento, Drupal, bespoke PHP) had no way to report a detection at all. This is that way.
It is a sensor, not a firewall. It observes and reports. It never blocks, never challenges, never modifies a response. Enforcement is a separate decision made elsewhere, with the evidence this produces.
composer require webdecoy/sensorPick the narrowest integration your stack allows. In order of preference:
You have a request object, so use it. Nothing global, nothing to remember.
use WebDecoy\Sensor\{Sensor, Config, Request};
$sensor = new Sensor(new Config(
apiKey: getenv('WEBDECOY_API_KEY'),
source: 'laravel_middleware',
trustedProxies: ['10.0.0.0/8'], // see below, this one matters
));
// Laravel: in a terminable middleware, so reporting happens after the
// response has already been sent to the visitor.
public function terminate($request, $response): void
{
$this->sensor->observe(new Request(
$request->method(),
$request->fullUrl(),
$request->server('REMOTE_ADDR'),
array_change_key_case($request->headers->all(), CASE_LOWER)
));
}For a host where you want coverage without touching application code.
; php.ini
auto_prepend_file = /path/to/vendor/webdecoy/sensor/bootstrap.php# .htaccess, which LiteSpeed also honours
php_value auto_prepend_file /path/to/vendor/webdecoy/sensor/bootstrap.phpConfigure it from the environment:
WEBDECOY_API_KEY=sk_live_...
WEBDECOY_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
WEBDECOY_SOURCE=php_sensorUnderstand what this switches on. A prepended file runs for every PHP
process on the host: web requests, cron jobs, queue workers, composer install.
The sensor skips the CLI by default for that reason, but the blast radius is the
whole machine rather than one application. Prefer a middleware where you have
one.
Two ways, because an operator whose site is misbehaving needs it off now, without a deploy and without waiting for us.
WEBDECOY_DISABLE=1 # if you can restart PHP# if you cannot: point at a path, then create the file
WEBDECOY_DISABLE_FILE=/var/run/webdecoy.disabled
touch /var/run/webdecoy.disabledEither one stops the sensor before it does anything else.
Default is empty, which means the socket address is used and forwarding headers are ignored. That is correct for a server with nothing in front of it and wrong for one behind a CDN or load balancer.
If you are behind Cloudflare, a load balancer, or any reverse proxy, list it:
trustedProxies: ['10.0.0.0/8', '172.16.0.0/12']Get this wrong and nothing errors. Every visitor collapses onto one address, the address of your proxy, which silently defeats rate limiting and makes actor correlation meaningless. It looks exactly like normal operation.
The sensor walks X-Forwarded-For from the right, skipping hops you have
declared trusted, and stops at the first one you have not. The leftmost entry is
whatever the client claimed, so it is never believed on its own.
Nobody browses to /.env by accident. A request for one is not suspicious, it
is conclusive: the caller is enumerating for credentials, and no JavaScript,
fingerprint, or reputation lookup was needed to know it. That makes a trap hit
the most valuable thing a server-side sensor can observe, and the cheapest,
since it is a string comparison on the path.
Eighteen paths are armed by default (credential files, source-control metadata, database dumps, debug endpoints). Add your own:
tripwirePaths: ['/admin-backup', '/old-wp-config.txt']WEBDECOY_TRIPWIRE_PATHS=/admin-backup,/old-wp-config.txtMatching a tripwire changes nothing about the response. The sensor does not block, redirect, or serve anything different. It reports, and reports honestly that a trap was reached. What happens next is a decision made elsewhere, with more context than a single request has.
Matching is an exact comparison on the normalized path (lowercased, query and fragment removed, trailing slash trimmed). Deliberately not a prefix or substring match: a legitimate page whose URL merely contains one of these strings would otherwise register as a trap hit, and a false positive on the heaviest signal we have costs more than a missed scan.
Trap hits are checked before the static-asset skip, because several bait paths
end in an extension that skip list would otherwise discard (/backup.zip,
/database.sql, /.DS_Store).
The same eighteen paths ship in the Node SDK and the WordPress plugin, so one request is a trap hit on every stack or on none.
The full wire contract is public: https://docs.webdecoy.com/reference/sensor-protocol/
Request metadata only: user agent, resolved client IP, URL, method, and the
client's header names plus Accept-Language and Accept-Encoding.
It does not send request or response bodies, cookie values, or credentials. Header names, not values, because the name set is what carries the signal and your visitors' header values are not ours to collect.
curl -A "WebDecoy-Test/1.0" https://your-site.example/A detection appears in your dashboard, labelled as a test and excluded from statistics, billing, and enforcement. If one does not appear, the sensor is not seeing the request, which is the failure worth catching: a sensor reporting nothing looks exactly like a quiet site.
- It never throws. Every path is wrapped. A sensor that raises into your application is a broken site, and no amount of bot visibility is worth that.
- It fails open. Unreachable, slow, or erroring: your request is served.
- It is bounded. 200ms to connect, 400ms total, abandoned rather than
retried. Under
auto_prepend_fileit reports at shutdown, after your response is already on its way. - It skips static assets. They are noise and they cost you volume.
PHP 8.1+, ext-curl, ext-json.
8.1 is the real floor, not a preference: the package uses readonly promoted
properties, which earlier versions cannot parse. Through v0.1.0 composer.json
claimed 7.4, so Composer would install happily on 7.4 or 8.0 and the code then
failed to parse. That matters more than a normal incompatibility here, because a
parse error happens before any of this package's error handling can run: under
auto_prepend_file it would take down every PHP request on the host. Declaring
8.1 makes Composer refuse the install instead.
The source of truth is php-sensor/ in the WebDecoy application monorepo.
WebDecoy/php-sensor, which is what Composer and Packagist read, is an output
of it.
Release from the monorepo:
cd php-sensor && ./release.sh 0.2.0That checks Sensor::VERSION matches the tag, runs the tests, syncs, pushes,
and tags. Do not edit WebDecoy/php-sensor directly: a change made there is
lost on the next release, and a hand-copied mirror is the drift this project has
already paid for once with the WordPress plugin.
This package is deliberately not bundled inside the WordPress plugin.
WordPress.org's Plugin Check reports every curl_* call as an error, so a
plugin shipping this file would fail review. Keeping the sensor standalone is
what lets a WordPress plugin pass review while non-WordPress PHP can still
report detections.