Skip to content
runomine

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.

Terminal · .env
# 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 GET requests answered 200 with text/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 your admin/* to skip.
  • 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 · bootstrap/app.php
// 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().

Anywhere in your app
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.

Your checkout controller
// ['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.

tests/Feature/SignupTest.php
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 5xx or 429 it retries twice with backoff; if that fails too, it drops the batch quietly.

  • No dependencies

    PHP 8.2+ with ext-curl and ext-json. No tables or migrations in your database.

FAQ

Frequently asked questions

Which Laravel and PHP versions does it support?
Laravel 10 and up, and PHP 8.2+, with ext-curl and ext-json. Without Laravel, the same package ships a framework-agnostic PHP client.
Does it work with Livewire and Inertia?
Each normal page load, yes. Livewire’s update requests (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?
No. The package has no migrations or tables, and sets no cookies or anything else in the browser. Events go to runomine in batches.
My app is behind Cloudflare. Anything special?
Configure trusted proxies so $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?
For visits and funnels, runomine is designed to fit the audience-measurement exemption (AEPD, CNIL). Attributing revenue to identified users goes further and may require consent. Decide it with your DPO.

Count what matters.

Visits, sales and servers in one place. Installed in less time than it took to read this page.