Guide · Laravel 10+ · PHP 8.2+
Cookieless analytics for Laravel.
One Composer package and one environment variable: your app starts counting pageviews from a middleware. No JavaScript, no cookies, and your visitors’ IP never leaves your server.
Then Rom::track() for what matters, and your payment provider for the money.
Reviewed on October 14, 2026
Install: one package, one token
The package registers itself (auto-discovery) and adds its TrackPageViews middleware to the web group. With RUNOMINE_TOKEN in your .env, it’s counting.
Rather not touch anything? runomine init detects Laravel, creates the site, writes the token into your .env without touching the rest, installs the package and waits for the first visit.
Beta: the package isn’t on Packagist yet. Until then it installs from its public repository, runomine/runomine-php.
# Beta: from the public repository until it's on Packagist
$ composer config repositories.runomine vcs https://github.com/runomine/runomine-php
$ composer require runomine/runomine-php:dev-main
# .env: the middleware starts counting pageviews
RUNOMINE_TOKEN=rm_live_…
# Or let the CLI do all of the above
$ runomine init
What the middleware counts, and what it doesn’t
- It counts
GETrequests answered200withtext/html: what your visitor actually saw. - It does so in
terminate(), once the response has gone out. Your visitor doesn’t wait. - It skips
api/*,livewire/*, Horizon, Telescope, Pulse and other internal routes, and browser prefetches. Add youradmin/*toskip. - Inertia visits and other client-side navigations answer JSON, so it doesn’t see them; for those, and for pages your CDN serves from cache, add the beacon.
- Behind a load balancer, configure trusted proxies: the hash uses
$request->ip(), and without that every visitor looks like your load balancer.
// config/runomine.php (php artisan vendor:publish --tag=runomine-config)
'skip' => ['api/*', 'up', 'livewire/*', 'horizon*', …, 'admin/*'],
'user' => null, // the signed-in user's id; false: never send it
// bootstrap/app.php (Laravel 11+): behind a load balancer, so $request->ip() is the visitor's
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*');
})
# .env, only behind Cloudflare without trusted proxies
RUNOMINE_IP_HEADER=CF-Connecting-IP
Business events with Rom::track
Inside a request, Rom::track() attaches the visitor hash, the URL, the country (when your CDN sends it) and the logged-in user’s id.
Queued jobs, commands and webhooks have no request, so the event goes without a visitor; never a made-up one. Pass the user, and runomine links it to their earlier visits, or hand the job the visitor with Rom::visitor().
use Runomine\Laravel\Facades\Rom;
// In a request: visitor hash, URL, country and user id go with it
Rom::track('signup');
Rom::track('checkout_started', ['plan' => 'pro']);
// A queued job has no request: hand it the visitor…
ProcessCheckout::dispatch($order, Rom::visitor());
// …and in the job
Rom::track('checkout_completed', context: ['visitor' => $this->visitor]);
// Or name the user, and runomine links it to their earlier visits
Rom::track('trial_converted', user: $team->owner_id);
Stripe or Paddle sales, by source
Amounts don’t come from your code: connect Stripe, Paddle, Lemon Squeezy, Shopify or PayPal in the site’s settings and runomine reads every sale, renewal and refund.
To know which source brought each sale, pass Rom::checkoutMetadata() when you create the checkout. It carries this request’s visitor and your user id; runomine reads them back from the provider. For Stripe subscriptions, also put it in subscription_data so renewals carry the user.
With a provider connected, don’t also send revenue with Rom::track('purchase'): the sale would count twice.
// ['runomine_visitor' => '5f9e1bebe31e8bf5', 'runomine_user' => '42']
$metadata = Rom::checkoutMetadata();
$session = $stripe->checkout->sessions->create([
'mode' => 'subscription',
'line_items' => [['price' => 'price_123', 'quantity' => 1]],
'success_url' => route('billing.done'),
'client_reference_id' => (string) $user->id,
'metadata' => $metadata,
'subscription_data' => ['metadata' => $metadata], // renewals
]);
// Paddle.js, in a Blade view
Paddle.Checkout.open({ items: […], customData: @json(Rom::checkoutMetadata()) });
In your tests: Rom::fake()
Like Mail::fake(): nothing leaves the test, and you can assert exactly what would have been sent. Also assertPageviewRecorded(), assertNothingTracked() and tracked('name') to inspect the payload.
To switch runomine off in tests without faking it, set RUNOMINE_ENABLED=false in your phpunit.xml.
use Runomine\Laravel\Facades\Rom;
public function test_signing_up_is_tracked(): void
{
$rom = Rom::fake(); // nothing leaves the test
$this->post('/register', ['email' => 'ana@example.com', …]);
$rom->assertTracked('signup');
$rom->assertTrackedTimes('signup', 1);
$rom->assertNotTracked('purchase');
}
Why it doesn’t slow your app down
-
After the response
Events are kept in memory and sent in a batch once the response has gone out, with a 2-second timeout.
-
Never throws
On a
5xxor429it retries twice with backoff; if that fails too, it drops the batch quietly. -
No dependencies
PHP 8.2+ with
ext-curlandext-json. No tables or migrations in your database.
FAQ
Frequently asked questions
Which Laravel and PHP versions does it support?
ext-curl and ext-json. Without Laravel, the same package ships a framework-agnostic PHP client.Does it work with Livewire and Inertia?
livewire/*) are skipped by default. Inertia visits answer JSON and the middleware doesn’t count them: for those, add the beacon, which follows pushState.Does it store anything in my database or the browser?
My app is behind Cloudflare. Anything special?
$request->ip() is the visitor’s IP. If you can’t, set RUNOMINE_IP_HEADER=CF-Connecting-IP (only then: that header is easy to spoof otherwise). The country comes from CF-IPCountry. Pages Cloudflare serves from cache never reach Laravel: add the beacon to those.Do I need a cookie banner?
Keep reading
- runomine vs Google Analytics What you gain and lose by leaving GA4, and how to switch without flying blind.
- runomine vs Plausible Two cookieless tools. The difference: where it’s measured and how the money arrives.
- Server-side analytics What measuring from the server means, what you see and what you miss, and how to count visitors without cookies.
Count what matters.
Visits, sales and servers in one place. Installed in less time than it took to read this page.