analytics-platform / platform
Firewall, error monitoring and server analytics for your PHP, Laravel or Symfony site, run from your analytics dashboard.
Requires
- php: >=7.4
Requires (Dev)
None
Suggests
- ext-apcu: Needed for the rate limit (or pass your own counter)
- doctrine/dbal: For AnalyticsPlatform\Symfony\QueryMiddleware (3.2 or later: Doctrine's query timings)
- symfony/framework-bundle: For AnalyticsPlatform\Symfony\PlatformSubscriber (Symfony 6.4 or later)
- symfony/http-client: For AnalyticsPlatform\Symfony\HttpClientBreadcrumbs (its requests before an error)
- symfony/monolog-bundle: For AnalyticsPlatform\Symfony\LogProcessor (the exceptions you log)
Provides
None
Conflicts
None
Replaces
None
README
One package for your PHP site's server, run from your analytics dashboard: a
firewall, error monitoring and server analytics (payments,
sign-ups, leads). PHP 7.4 or later, no dependencies. For WordPress, use the
Site Analytics plugin (sdk/wordpress-plugin), which is built on this.
composer require analytics-platform/platform
$platform = new AnalyticsPlatform\Platform([ 'site_id' => 'YOUR_SITE_ID', 'secret' => getenv('ANALYTICS_SITE_SECRET'), // site settings; keep it on the server 'endpoint' => 'https://YOUR_COLLECTOR_URL', // 'trust_proxy' => 1, // behind Nginx or a load balancer // 'trust_proxy' => 'cloudflare', // every request through Cloudflare (CF-Connecting-IP is // // believed only from Cloudflare's own addresses) // 'storage' => '/var/cache/analytics-platform', // default: the system temp dir ]); $platform->protect(); // as early as possible, before any output
To protect every PHP file without touching your code, put those lines in a
file and load it with auto_prepend_file in php.ini or .user.ini.
trust_proxy counts the proxies that add to X-Forwarded-For, from the
right; the hop it lands on is the visitor. A port on a hop
(203.0.113.5:54321, [2001:db8::1]:443, as Azure's load balancers send)
is dropped, and a hop that isn't an address gives the connection's address
instead, so the request is still checked. If your web server already puts the
visitor in REMOTE_ADDR — Apache's mod_remoteip, nginx's real_ip in front
of PHP-FPM, LiteSpeed's "Use Client IP in Header", Cloudflare's own module —
leave trust_proxy at 0: REMOTE_ADDR is then the visitor, and mod_remoteip
also takes the entries it used out of the header, so counting them again would
read one the visitor wrote. PHP can't tell that from the request, so it is
yours to set right. Laravel and Symfony don't use trust_proxy at all: their
own trusted proxies decide ($request->ip(), getClientIp()).
Modules
| Module | What it does | Default |
|---|---|---|
firewall |
Blocks addresses, known criminal networks, shared blocks and floods (Security → Firewall) | on |
firewall's attack patterns |
Rules that match the request (its path, query string and user agent), not just the address, so an attack is turned away before your site runs — a package with a known hole stays safe until you update it. They come with the firewall's rules and are managed on the Security page (Firewall → Attack patterns); a pattern a real visitor might trip only watches until you promote it. Nothing to configure (protocol: sdk/protocols/firewall.md) |
with the firewall |
proxy |
Serves the tracker and takes visits under your own domain, so ad blockers can't hide those visitors | on |
errors |
Uncaught exceptions and fatal errors in the Errors report | on |
analytics |
track(), identify() and lead() from your server |
on |
audit |
$platform->audit('user.role_changed', $actor, $target, $details); WordPress, Laravel and Symfony record their usual events on their own |
on |
flags |
$platform->isEnabled('new-checkout', $user->id) (WordPress: site_analytics_flag('key')) |
on |
maintenance |
A "back soon" page while maintenance mode is on in the dashboard (WordPress: front end only, editors see the site) | on |
headers |
Adds the security headers you pick on the Security page; your own headers win | on |
forms |
Checks forms before you accept them: $platform->checkForm() (Laravel: the ProtectForm middleware) |
on |
performance |
Response times and errors per route (Health → Server speed); name a route with $platform->setRoute('/orders/{id}') |
on |
queries |
With performance: every database query's time per route (Health → Server speed → Queries), its values replaced by ? before anything is counted. Laravel: automatic; Symfony: QueryMiddleware; plain PHP: TimedPdo; MongoDB: automatic with the mongodb extension; WordPress: automatic (see "Database queries") |
on |
crawlers |
Which search engines and AI bots fetch your pages (Health → Crawlers). Your tracker can't see them: crawlers don't run JavaScript | on |
inventory |
Once a day, after a response, reports your Composer packages (composer.lock, not dev packages) and PHP version, so Security → Security check flags known security holes in them. Names and versions only. 'inventory' => ['root' => '/path/with/composer.lock'] when it isn't the document root's parent (protocol: sdk/protocols/inventory.md; WordPress: plugins, themes and core too, through WP-Cron) |
on |
posture |
Once a day, how the server is set up: the php.ini settings that leave it open, and a walk of the web folder for code anyone can write and files that shouldn't be public (a database dump, a .env, a backup copy). Security → Security check says what to change, and asks your site for each of those files to see whether it really is downloadable. Reads only, and sends no file's contents. 'posture' => ['roots' => [__DIR__ . '/public']]; Laravel: ANALYTICS_POSTURE=true (protocol: sdk/protocols/posture.md; WordPress: its own settings, admin accounts and scheduled jobs too, through WP-Cron) |
off |
files |
File monitoring: 'files' => ['roots' => [__DIR__ . '/public']], then $platform->checkFiles() from a cron job, hourly; $platform->checkFiles(true, true) after a deploy accepts the files as they are (protocol: sdk/protocols/files.md). Changed code, backdoors and defacement show in Security → Page integrity (WordPress: automatic, through WP-Cron) |
off |
Each takes true, false or its options ('errors' => ['before_send' => ...]).
They can also be switched off in the dashboard (Security → Server package).
How it works
- Nothing waits on us. PHP keeps nothing between requests, so the
firewall's rules and the module switches are kept in
storageas PHP files that OPcache holds in memory. Everything that talks to us (fetching rules, sending errors and events) runs after the response has gone out (PHP-FPM). - Keep
storageout of the web root. It holds the firewall's events (visitor addresses and what they asked for), crawler hits and, with file monitoring on, a list of every path on the site. The folder inside it is named after your site id and a hash of your secret, so its address can't be worked out from the site id in your pages, and an.htaccessdenying everything is written next to the files — but nginx ignores.htaccess, so a folder outside the web root is the only real answer. The default (the system temp directory) already is one. - Fails open. No rules yet, or our service unreachable: requests pass.
- Rate limit needs a counter shared between requests: the APCu extension,
or your own (
'counter' => function ($key, $ttl) { ... return $hits; }). Without one, the rate limit is skipped and the dashboard says so. The rate limit, failed logins and bans here count an IPv6 address by its /64 (one machine usually holds the whole /64, so rotating within it doesn't reset the count) and::ffff:a.b.c.dasa.b.c.d. Static files (.css,.js, images, fonts...) aren't counted, since a page asks for dozens; one that answers an error (a flood of made-up/x.cssreaching PHP) is counted after the response. - One deadline after the response. Everything sent after a response shares 5 seconds: a collector that doesn't answer costs a PHP worker that, not one timeout per module, and what wasn't sent waits on disk for a later request. A batch is removed from disk only once it was sent, so a request killed while sending leaves it for the next.
Bot protection
Call $platform->loginFailed() when a login fails (WordPress, Laravel and
Symfony do it on their own). Past the site's limit the address is banned: straight away with
APCu, else on every server with the next rules (within a minute or two).
Floods are banned the same way, and so are addresses that ask for files no
visitor ever does (/.env, /.git/, backups: the dashboard sends the list),
when the firewall is in block mode.
Browser check
On paid plans, when you turn on flood protection's browser check in the dashboard, a visitor's first page each day is a small page that sets a cookie with JavaScript and reloads into your site (a blink, once a day per address). Nothing to configure: it comes with the firewall's rules, in every mode.
- What it stops: cheap floods — scripts and bots that fetch your pages without running JavaScript.
- What it doesn't: a real (or headless) browser, which passes it like a visitor does; attacks on forms, APIs or logins, which aren't page loads.
- A speed bump, not a lock: it stops scripts that don't run JavaScript. A bot written for this product can read the pass from the page, which is why the pass is bound to the visitor's address and the day.
- Only page loads are checked:
GETandHEADthat ask fortext/html. Never aPOST(forms, webhooks, payment callbacks), never an API orfetch()call, curl or a feed reader, never the paths the dashboard skips (/api/,/wp-json/…), your allowed addresses, the AI crawlers that bring visits, or the search engines' published addresses. - The cookie
__sg_passholds a day number and a signature of the visitor's address (an IPv6 address's /64) made with your site secret, lasts a day and is sent only to your site. It is strictly necessary for security, identifies no one and isn't used for analytics, so it needs no consent. - The page has its own
Content-Security-Policy, replacing yours for that one response. A policy added by your web server (nginx, Apache) is added on top and can block it: leave that header to PHP or allow the page's nonce. In Symfony the package puts its policy back after every other response listener (NelmioSecurityBundle's too). In Laravel, putFirewallMiddlewareoutside any middleware that setsContent-Security-Policy, or that one replaces the page's and no visitor gets past it. - Pages your server answers from a cache before PHP runs aren't checked.
on_blockisn't called for it: the page has to be the package's to set the pass. Elsewhere,check()returnsrulechallengewith status 503, and$platform->firewall()->challengePage($ip, $method)gives the status, headers and body to answer with.
Server analytics and errors
$platform->track('purchase', ['numeric_value' => '49.90', 'currency' => 'EUR', 'transaction_id' => $order->number]); $platform->identify($user->id); $platform->lead(['email' => 'ana@example.com', 'name' => 'Ana', 'status' => 'qualified']); // waits for the answer try { chargeCard($order); } catch (PaymentException $e) { $platform->captureException($e, null, 502); throw $e; }
The visit is read from the X-Analytics-Session header (send
window.analyticsSession.getId() with your requests) or the
analytics_session cookie, which the WordPress plugin sets. An error carries
the route setRoute() named. One exception is sent once however often it is
captured, and a batch the collector couldn't take is kept on disk and sent by
a later request; each error keeps the time it happened.
An error also shows the steps before it in its request or job. Add your own
with $platform->addBreadcrumb('Order saved'). In Laravel, calls made with its
Http client are recorded on their own, with their address (no query string)
and status; in Symfony, its HttpClient's (HttpClientBreadcrumbs). Plain PHP can't see outgoing calls, so there it's only your own
steps. To send none, unset $event['breadcrumbs'] in before_send.
Background jobs (a worker, a cron script):
$platform->job('send-receipts', function () { /* ... */ });
Their errors carry the job's name as the route, a job that throws is sent
(status 500) and thrown on, each run is timed (Server speed → Jobs), and on
the command line errors are sent after every job. Several services on one
site: 'server_name' => 'worker' in the options. Health → All systems lists
each by that name; PHP checks in only while it serves requests, so a quiet
site is never called down (sdk/protocols/checkin.md).
Database queries
Every query is timed, its values replaced by ? (SELECT * FROM users WHERE id = ?;
bound parameters are never read), and counted under the request it ran in, so
Server speed shows how much of a route's time was the database and which query.
A queue job's queries are its own; one outside any request or job is (background).
Protocol: sdk/protocols/queries.md. It rides on the performance module and its
dashboard switch; 'queries' => false keeps Server speed without it.
- Laravel: automatic (
QueryExecuted, whatDB::listenhears). Laravel announces no failed query, so a failure is counted when itsQueryExceptionis reported (with no time); one you catch and don't report isn't.ANALYTICS_QUERIES=falseturns it off. - Symfony: the Doctrine middleware, below.
- Plain PHP:
new AnalyticsPlatform\TimedPdo($dsn, $user, $password)where you hadnew PDO(...).exec(),query()and prepared statements'execute()are timed; prepared statements aren't on a persistent connection or with a statement class of your own. Your own hook for anything else:$platform->recordQuery('postgresql', $sql, $ms, $failed). - MongoDB: with the
mongodbextension loaded, a command subscriber is added on its own (find users {"email":"?"}); Laravel's mongodb connections are left to it. - WordPress: the plugin writes
wp-content/db.phpon activation, when there is none and the folder can be written, and removes it on deactivation. Otherwise it reads WordPress'sSAVEQUERIESlog at the end of the request, which misses the queries before plugins load and counts no failures. - Workers serving many requests from one process: Octane and the Symfony
subscriber start each request's queries afresh on their own; elsewhere call
$platform->beginRequest()as each request starts.
Laravel
composer require analytics-platform/platform is all: package discovery adds the
middleware in front of every request, and errors Laravel reports go to the
Errors report too. Set in .env:
ANALYTICS_SITE_ID=YOUR_SITE_ID
ANALYTICS_SITE_SECRET=...
ANALYTICS_FIREWALL_ENDPOINT=https://YOUR_COLLECTOR_URL
In your code: app(AnalyticsPlatform\Platform::class)->track('purchase', [...]).
The visitor's address is $request->ip(), so set up Laravel's trusted proxies
if you're behind a load balancer or Cloudflare. The rate limit counts in your
cache store (not the array driver). Modules: ANALYTICS_FIREWALL,
ANALYTICS_ERRORS, ANALYTICS_SERVER_EVENTS, ANALYTICS_INVENTORY, ANALYTICS_QUERIES (false turns one off).
ANALYTICS_CAPTURE_LOGS=true also sends exceptions you log at error or above
(Log::error('...', ['exception' => $e])). It works the same under Octane
(Swoole, RoadRunner, FrankenPHP): nothing of one request is kept for the next.
Queue jobs (queue:work) are
wrapped as jobs on their own; ANALYTICS_SERVER_NAME=worker names the service. To
protect only some routes, set ANALYTICS_FIREWALL_GLOBAL=false and add
AnalyticsPlatform\Laravel\FirewallMiddleware to them.
Symfony
Symfony 6.4 or later. After composer require analytics-platform/platform,
add these services to config/services.yaml, below the _defaults it
already has:
AnalyticsPlatform\Platform: arguments: $options: site_id: '%env(default::ANALYTICS_SITE_ID)%' secret: '%env(default::ANALYTICS_SITE_SECRET)%' endpoint: '%env(default::ANALYTICS_FIREWALL_ENDPOINT)%' platform: symfony storage: '%kernel.project_dir%/var/analytics-platform' inventory: { root: '%kernel.project_dir%' } counter: '@AnalyticsPlatform\Symfony\CacheCounter' AnalyticsPlatform\Symfony\CacheCounter: ~ AnalyticsPlatform\Symfony\PlatformSubscriber: ~ # With symfony/http-client: its requests in the steps before an error. AnalyticsPlatform\Symfony\HttpClientBreadcrumbs: ~ # Optional: also send the exceptions you log at error or above. # AnalyticsPlatform\Symfony\LogProcessor: ~ # With Doctrine DBAL 3.2 or later: every query's time (Server speed → Queries). AnalyticsPlatform\Symfony\QueryMiddleware: tags: [doctrine.middleware]
and the same three keys as Laravel to .env.local. There is no bundle, so
nothing goes in bundles.php. Until the keys are set the site runs as before.
The subscriber runs before Symfony's own listeners on every request: the
tracker's addresses, the firewall and maintenance mode, then the security
headers on the response. An exception is sent when the visitor got a 5xx, so
a 404, an access denied, or an exception that framework.exceptions or
#[WithHttpStatus] turns into a 4xx is not. Server speed names each request by
its route's path (/users/{id}); the paths are read from your routes once and
kept next to the router's cache, so cache:clear reads them again. It works
the same under PHP-FPM and in a worker that serves many requests from one
process (FrankenPHP, RoadRunner, Swoole): nothing of one request is kept for the next.
Messenger workers (messenger:consume) are wrapped as jobs, named after the
message's class, and a command that fails is sent too. Failed form and JSON
logins count for bot protection, and logins and logouts go to the audit log.
In your code, ask for AnalyticsPlatform\Platform in a controller or service:
$platform->track('purchase', [...]).
- Logged exceptions.
LogProcessorsends what you log with an exception at error or above ($logger->error('...', ['exception' => $e])), from any channel, like Laravel'sANALYTICS_CAPTURE_LOGS. Needs MonologBundle, which picks it up on its own: nothing goes inmonolog.yaml. One logged and then thrown is sent once. - HttpClient.
HttpClientBreadcrumbsrecords each request your HttpClient makes (the default client and scoped ones): method, address without the query string, status, 0 when no answer came. Leave the line out if you don't usesymfony/http-client. - Only some routes.
protect_only: ['/admin', 'checkout', 'account_*']under$optionsputs the firewall and maintenance mode in front of those paths (and everything below them) and route names (*at the end: every name that starts so) and nowhere else. Timing, errors and the tracker's addresses stay on everywhere. - Database queries.
QueryMiddlewareis a Doctrine DBAL middleware (DoctrineBundle 2.6 or later puts it on every connection by its tag): the ORM's queries and your ownConnectioncalls, failures included. A query that fails while being prepared, before it runs, isn't counted. - Rate limit.
CacheCountercounts in APCu when the server has it, else in your app's cache (cache.app). A cache pool can't add one atomically, so under a burst of simultaneous requests it counts a little low; APCu is exact.
The visitor's address is $request->getClientIp(), so set
framework.trusted_proxies if you're behind a load balancer or Cloudflare.
Every option of the plain PHP package goes under $options too:
server_name: worker, proxy: false, firewall: { mode: monitor },
posture: { roots: ['%kernel.project_dir%/public'] }.
Ad blockers
Many visitors use an ad blocker, and most blockers stop analytics. They recognise the address a script comes from and what it is called, so a tracker loaded from us is simply never counted.
Your site already runs on your own domain, so the package serves the tracker
and takes the visits itself. protect() (or Laravel's middleware, or Symfony's subscriber) answers
those two addresses before anything else; put them in your page's tag.
$proxy = $platform->proxy(); ?> <script async src="<?= $proxy->scriptUrl() ?>" data-siteid="..." data-domain="example.com" data-collect="<?= $proxy->collectUrl() ?>"></script>
- The path comes from your secret (
/3f9a2c1b/...), so it isn't in the page and isn't a name any blocker lists. Pick your own with'proxy' => ['prefix' => '...']. - Tracker files are kept in the storage folder, so a repeat visitor costs no request of ours; if we can't be reached, the copy already there is served rather than nothing.
- A visitor's request waits on ours, so ours are short (1 second to connect, 2
in all), and after three failures in a row we are left alone for 30 seconds:
beacons get a
502withRetry-Afterstraight away (the tracker treats a lost beacon as lost and carries on) and a tracker file that failed isn't asked for again for 30 seconds. That state is shared between requests in APCu, else in a small file in the storage folder. - A denied or banned address gets nothing from the proxy either (and is reported); beacons are never rate-limited, checked or matched against request rules.
- The visitor's address goes on as
X-Forwarded-For(your collector needs it for the country, the daily visitor ID and the rate limit) and cookies never leave your server. - Beacons are answered before the rest of the firewall, the crawler count and the timing, so your own analytics traffic doesn't appear as pages of your site.
- WordPress does it for you: the plugin adds a rewrite and answers through
px.php, without loading WordPress at all. Settings → Site Analytics → Ad blockers says whether it's working. - Switch it off with
'proxy' => false(Laravel:ANALYTICS_PROXY=false; Symfony:proxy: false), or in the dashboard (Security → Server package).
Other frameworks
$platform->firewall()->check($ip, $method, $path, $userAgent, $accept, $passCookie)
returns the decision without ending the request ($accept is the Accept
header and $passCookie the __sg_pass cookie, best as
\AnalyticsPlatform\Challenge::cookieValues($cookieHeader) from the raw
Cookie header, which a browser can send twice, for the browser check; leave
them out and no request is checked). When its rule is challenge, answer
with $platform->firewall()->challengePage($ip, $method). Call
$platform->afterResponse() once the response has gone out.