Search by

Firewall, error monitoring and server analytics for your PHP, Laravel or Symfony site, run from your analytics dashboard.

Package info

github.com/StevieC87/analytics-platform-php

pkg:composer/analytics-platform/platform

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.4 2026-11-04 10:30 UTC

This package is auto-updated.

Last update: 2026-11-04 10:36:11 UTC


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 storage as 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 storage out 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 .htaccess denying 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.d as a.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.css reaching 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: GET and HEAD that ask for text/html. Never a POST (forms, webhooks, payment callbacks), never an API or fetch() 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_pass holds 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, put FirewallMiddleware outside any middleware that sets Content-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_block isn't called for it: the page has to be the package's to set the pass. Elsewhere, check() returns rule challenge with 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, what DB::listen hears). Laravel announces no failed query, so a failure is counted when its QueryException is reported (with no time); one you catch and don't report isn't. ANALYTICS_QUERIES=false turns it off.
  • Symfony: the Doctrine middleware, below.
  • Plain PHP: new AnalyticsPlatform\TimedPdo($dsn, $user, $password) where you had new 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 mongodb extension 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.php on activation, when there is none and the folder can be written, and removes it on deactivation. Otherwise it reads WordPress's SAVEQUERIES log 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. LogProcessor sends what you log with an exception at error or above ($logger->error('...', ['exception' => $e])), from any channel, like Laravel's ANALYTICS_CAPTURE_LOGS. Needs MonologBundle, which picks it up on its own: nothing goes in monolog.yaml. One logged and then thrown is sent once.
  • HttpClient. HttpClientBreadcrumbs records 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 use symfony/http-client.
  • Only some routes. protect_only: ['/admin', 'checkout', 'account_*'] under $options puts 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. QueryMiddleware is a Doctrine DBAL middleware (DoctrineBundle 2.6 or later puts it on every connection by its tag): the ORM's queries and your own Connection calls, failures included. A query that fails while being prepared, before it runs, isn't counted.
  • Rate limit. CacheCounter counts 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 502 with Retry-After straight 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.