Search by

privatus / analytics

ja-andrews

Adds the cookieless Privatus Analytics tracking script to PHP, Laravel and Symfony/Twig apps. It only prints HTML and never sends data from your server.

v1.0.0 2026-10-25 10:35 UTC

This package is not auto-updated.

Last update: 2026-10-26 02:58:05 UTC


README

Real visitor numbers for your PHP app. No cookies, no cookie pop-up, no bots.

You want to know if people use your PHP app. But Google Analytics uses cookies, so in many countries you must show a cookie pop-up first (the box that asks visitors to say yes to cookies). Many people click no, so they never show up in your numbers. Bots add fake visits on top.

This package adds the Privatus Analytics script to your PHP app. Laravel gets Blade directives, a Blade component and a middleware. Symfony and other Twig apps get a Twig extension. The script sets no cookies and keeps no IP addresses. Bots are filtered out before they are counted.

So you see how many real people came, which pages they looked at and where they came from, on one simple dashboard. A report comes to your inbox each week, and you can set alerts for when traffic jumps or drops. And you don't need a cookie pop-up for your analytics.

Free plan, no card: 10,000 events a month (an event is a page view or an action you track) on up to 3 sites. Start free at privatusanalytics.com.

  • No cookies. No cookies and no hidden IDs on your visitors' devices, and no IP addresses are kept. Laws differ by country, so check your own legal advice.
  • Real people, not bots. Known bots, data centers and spam visits are left out on every plan.
  • Small and fast. The script is about 3 KB when compressed.

What this package does

  • Works with plain PHP.
  • A Laravel package: Blade directives, a Blade component and an optional middleware.
  • A Twig extension for Symfony and other Twig apps.
  • The package only prints HTML: the browser script tag and some HTML attributes. It never sends events from your server, sets no cookies, and never reads or stores IP addresses or other details about visitors.

You need a Privatus Analytics account to see your reports, and there is a free plan. Sign up at privatusanalytics.com.

Requirements

  • PHP 8.1 or later.
  • For Laravel: Laravel 12 or 13 (they need PHP 8.2 or later).
  • For Twig: Twig 3.
  • A Privatus Analytics site id (it starts with pa_). You find it under Site settings, Tracking.

Install

composer require privatus/analytics

For the full setup guide and every tracker option, see the Privatus Analytics docs.

Set your site id with an environment variable:

PRIVATUS_SITE_ID=pa_YOURSITEID

Add the tag

Laravel

Laravel finds the package on its own. Put the directive in the <head> of your layout, for example resources/views/layouts/app.blade.php:

<head>
    <title>My app</title>
    @privatusAnalytics
</head>

Or use the component:

<x-privatus-analytics />

In production it prints:

<script defer src="https://privatusstats.com/js/pa.js" data-site="pa_YOURSITEID"></script>

The tag prints nothing when there is no site id, and nothing outside the production environment (see below). If your app uses a Content Security Policy nonce (a one-time code that lets a script run) through Vite::useCspNonce(), the tag adds it for you.

To change the settings, publish the config file:

php artisan vendor:publish --tag=privatus-config

That creates config/privatus.php.

Symfony and other Twig apps

Register the Twig extension as a service in config/services.yaml:

parameters:
    env(PRIVATUS_SITE_ID): ''

services:
    Privatus\Analytics\Config:
        arguments:
            $siteId: '%env(PRIVATUS_SITE_ID)%'
            $environment: '%kernel.environment%'
            $enabledEnvironments: ['prod']

    Privatus\Analytics\Twig\PrivatusExtension:
        arguments:
            $config: '@Privatus\Analytics\Config'
        tags: ['twig.extension']

Symfony names its production environment prod, so set $enabledEnvironments as above. The parameters line stops Symfony from failing when the variable is not set (the tag then prints nothing). With autoconfigure: true (the Symfony default) you can leave out the tags line. Then, in your base template:

<head>
    <title>My app</title>
    {{ privatus_analytics() }}
</head>

For a Content Security Policy nonce, pass it in. With NelmioSecurityBundle:

{{ privatus_analytics({nonce: csp_nonce('script')}) }}

Outside Symfony, add the extension yourself:

$twig->addExtension(new \Privatus\Analytics\Twig\PrivatusExtension(
    new \Privatus\Analytics\Config(siteId: 'pa_YOURSITEID', environment: 'production'),
));

Plain PHP

use Privatus\Analytics\Config;
use Privatus\Analytics\Tag;

$config = new Config(siteId: 'pa_YOURSITEID', environment: 'production');
echo new Tag($config);

The environment comes from environment, then the APP_ENV environment variable. If neither is set, the package treats it as development and prints nothing. Set enabled: true to print the tag anyway.

Or let the package add it (Laravel)

If you would rather not touch your layouts, switch on the middleware in config/privatus.php:

'auto_inject' => true,

It adds the tag just before </head> on every page in the web middleware group. It only changes text/html responses with status 200 that are not compressed. It skips Ajax requests, file downloads, streamed responses, pages without a <head>, and pages that already have the tag. It also fixes the Content-Length header.

You can also add the middleware to some routes only, and leave auto_inject off:

use Privatus\Analytics\Laravel\InjectPrivatusAnalytics;

Route::middleware(InjectPrivatusAnalytics::class)->group(function () {
    // ...
});

Configuration

Every setting is optional except the site id. In Laravel they live in config/privatus.php. Elsewhere, pass them to new Config(...) as named arguments (siteId, excludedPaths and so on), or to Config::fromArray() with the snake_case names below.

// config/privatus.php
return [
    'site_id'              => env('PRIVATUS_SITE_ID'),
    'script_host'          => env('PRIVATUS_SCRIPT_HOST', 'https://privatusstats.com'),
    'modules'              => ['engage', 'auto', 'vitals', 'clicks', 'ask'],
    'excluded_paths'       => ['/admin/*', '/preview/*'],
    'domains'              => ['example.com', 'www.example.com'],
    'spa'                  => 'auto',   // auto, history, hash or off
    'manual'               => false,    // true turns off the automatic pageview
    'enabled'              => env('PRIVATUS_ENABLED'),
    'enabled_environments' => ['production'],
    'auto_inject'          => false,
    'extra_attributes'     => ['data-mask' => '/orders/*->/orders/:id'],
    'props'                => null,
    'queue_stub'           => false,
];
Setting Default What it does
site_id PRIVATUS_SITE_ID The data-site attribute. Nothing prints without it
script_host https://privatusstats.com Where /js/pa.js is served (or PRIVATUS_SCRIPT_HOST). Change it only if you host your own copy
modules tracker default (engage) The data-modules attribute. Setting it replaces the default, so keep engage for time on page and scroll depth. [] loads none
excluded_paths none The data-exclude attribute. Path patterns the tracker never counts
domains all hosts The data-domains attribute. Count only these hostnames
spa auto The data-spa attribute, for single-page apps
manual false The data-manual attribute
enabled_environments ['production'] Environments that get the tag
enabled null true or false forces the tag on or off in every environment
environment the framework's own The current environment. Laravel and Symfony fill it in. Plain PHP reads APP_ENV
auto_inject false Laravel only. Adds the tag with a middleware
extra_attributes [] Any other data-* attribute
nonce the framework's own Content Security Policy nonce for the script tags. Laravel uses Vite::cspNonce()
props null Page properties. See Product analytics
queue_stub false Prints a small script so privatus.track() works before the tracker loads. It still prints when the tag is off, so those calls never fail

Each tracker attribute is explained in the script attributes reference, and the modules in Modules.

You can also change a setting for one page. Every setting works here:

@privatusAnalytics(['modules' => ['engage', 'auto'], 'excluded_paths' => ['/preview/*']])
<x-privatus-analytics :modules="['engage', 'auto']" />
{{ privatus_analytics({modules: ['engage', 'auto'], excluded_paths: ['/preview/*']}) }}
echo new Tag($config, ['modules' => ['engage', 'auto']]);

Development and test

Development, test and staging requests are not counted by default, so your own clicking around never reaches your reports. To see the tag while you build, set PRIVATUS_ENABLED=true (or enabled: true). The tracker also ignores localhost unless you pass data-allow-local or data-debug (see Debug mode):

'extra_attributes' => ['data-debug' => 'true'],

Leaving people out

Use data-exclude through excluded_paths for admin areas. To leave out signed-in staff, print the tag only for everyone else:

@unless(auth()->user()?->is_admin)
    @privatusAnalytics
@endunless
{% if not is_granted('ROLE_ADMIN') %}{{ privatus_analytics() }}{% endif %}

Product analytics: see which features people use

These helpers print plain HTML attributes. The tracker in the browser reads them and sends the events. Your server never sends anything.

Events on buttons, links and forms

<button @privatusEvent('Report exported', ['format' => 'csv', 'source' => 'toolbar'])>Export</button>
<button {{ privatus_event('Report exported', {format: 'csv', source: 'toolbar'}) }}>Export</button>
use Privatus\Analytics\EventAttributes;

echo '<button ' . EventAttributes::render('Report exported', ['format' => 'csv']) . '>Export</button>';

All three print:

<button data-privatus-event="Report exported" data-privatus-prop-format="csv" data-privatus-prop-source="toolbar">Export</button>

Put it on a <form> and the event fires when the form is sent:

<form method="post" action="/invite" @privatusEvent('Teammate invited', ['role' => 'editor'])>

The rules:

  • The name is trimmed and cut to 120 characters. An empty name prints nothing.
  • Property names are made lowercase. Anything other than a-z, 0-9 and _ becomes _. They are cut to 64 characters.
  • At most 30 properties. Empty (null) values are left out.
  • Values become text: true and false, numbers as written, lists joined with commas. Each is cut to 500 characters.
  • Everything is HTML-escaped, so it is safe to use with any value.
  • The attributes print even when the tag is off, so your development and production HTML match.

Click maps

Give the elements you care about a stable name, so click maps group them even when the page changes:

<a href="/pricing" @privatusClick('pricing.cta')>See pricing</a>
<a href="/pricing" {{ privatus_click('pricing.cta') }}>See pricing</a>
use Privatus\Analytics\ClickAttribute;

echo '<a href="/pricing" ' . ClickAttribute::render('pricing.cta') . '>See pricing</a>';

This prints data-privatus-click="pricing.cta". A name can use letters, digits and _ . : -, and be 1 to 100 characters long. Any other name prints nothing. In Laravel with APP_DEBUG=true, a warning goes to your log too.

Page properties (plan, role, app version)

Page properties go with every pageview and event on the page. Use them to split your reports by plan, role, app version, feature flags or customer tier.

In Laravel, set them for every page from a service provider's boot method. The closure gets the request:

use Illuminate\Http\Request;
use Privatus\Analytics\Laravel\Facades\Privatus;

public function boot(): void
{
    Privatus::props(fn (Request $request) => [
        'plan' => $request->user()?->plan,
        'role' => $request->user()?->role,
        'app_version' => config('app.version'),
    ]);
}

If you cache your config, a closure cannot live in config/privatus.php. There, props takes an array, or the name of an invokable class that gets the request and returns an array.

You can also set them for one page:

<x-privatus-analytics :props="['plan' => 'pro', 'beta' => true]" />
{{ privatus_analytics({props: {plan: 'pro', beta: true}}) }}

In Symfony, pass a closure to the extension, for example with the !closure tag (Symfony 6.3 or later) and an invokable service of your own that reads the request stack:

    Privatus\Analytics\Twig\PrivatusExtension:
        arguments:
            $config: '@Privatus\Analytics\Config'
            $props: !closure '@App\Analytics\PageProps'

With properties, the tag prints a small script first and points the tracker at it:

<script>window.privatusPageProps=function(){return {"plan":"pro","role":"admin"}}</script>
<script defer src="https://privatusstats.com/js/pa.js" data-site="pa_YOURSITEID" data-props="privatusPageProps"></script>

The names follow the same rules as event properties. Numbers and true or false stay as they are, and lists stay lists (at most 30 items). The characters <, > and & are escaped, so a value can never break out of the script.

Never put names, emails, user ids or anything else that points to one person in page properties. Privatus Analytics counts visitors without any id. Put groups (plan, role), not people. The server also removes emails and long ids it finds.

Track from your own JavaScript

privatus.track("Feature used", { feature: "export" });

If your code can run before the tracker loads, add the queue script. Calls made early wait in a queue and are sent once the tracker is ready. Set queue_stub to true, or for one page:

@privatusAnalytics(['queue_stub' => true])

It prints this before the tag (with your nonce, if you have one):

<script>window.privatus=window.privatus||{q:[]};['track','pageview','props','optOut','optIn','ready'].forEach(function(m){window.privatus[m]=window.privatus[m]||function(){window.privatus.q.push([m,Array.prototype.slice.call(arguments)])}})</script>

The queue script prints even where the tag is off (in development, with enabled set to false, or with no site id). Then only the queue script prints: no tracker and no page properties. So you can always call privatus.track(...). In development the calls just wait in the queue and are never sent. The middleware adds nothing when the tag is off.

See the JavaScript API docs.

Naming events

Use "Noun verb" names in the past tense, such as Report exported, Teammate invited or Plan upgraded. Keep the name short and the same everywhere, and put the details in properties (format: csv), not in the name. Then you can build:

  • Funnels to see where people stop.
  • Click maps to see what people click.
  • Goals to count the actions that matter.
  • Events for everything else.

Privacy

The script sets no cookies and stores nothing in the browser. This package sends nothing from your server. See the privacy documentation for what is collected.

If you have a privacy policy, it should say which tools handle your visitors' data. You can paste this:

This website uses Privatus Analytics to count visits. It does not use cookies and does not follow you from one website to another. Your IP address is used for a moment to count visits and is never stored.

Change it to fit your site. The privacy policy generator makes a longer version with your site's name in it.

Development

The checks run in Docker, so your local PHP does not matter:

./run-docker.sh

It uses the official php:8.3-cli image with Composer from the official composer image. It runs the PHPUnit tests (with a small Laravel app from Orchestra Testbench, and Twig), PHP-CS-Fixer (PSR-12 style), PHPStan (level 8) and a package build with composer archive.

Contributing

Bug reports and pull requests are welcome at https://github.com/Privatus-Analytics/php.

License

MIT. See LICENSE. Privatus Analytics is a product of Two Phase LLC, Wyoming, USA.