Search by

runomine / runomine-php

runomine

Cookieless, server-side analytics for runomine: pageviews, business events and revenue. Framework-agnostic client plus Laravel integration.

Package info

github.com/runomine/runomine-php

Homepage

pkg:composer/runomine/runomine-php

Statistics

Installs: 17

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-10-14 22:15 UTC

This package is auto-updated.

Last update: 2026-10-14 22:16:19 UTC


README

The PHP library of runomine: cookieless analytics that counts money, not just visits. It sends pageviews and business events (signups, purchases, revenue) from your backend, so the visitor's IP never leaves your server, and the library sets nothing in the browser.

  • Framework-agnostic Runomine\Client, plus a Laravel integration (service provider, middleware, Rom facade).
  • PHP 8.2+, ext-curl and ext-json. No other runtime dependency.
  • Never blocks the page and never throws: events are batched in memory and sent after the response.

Install

From Packagist:

composer require runomine/runomine-php

Or straight from this repository:

composer config repositories.runomine '{"type":"vcs","url":"https://github.com/runomine/runomine-php"}'
composer require runomine/runomine-php:dev-main

Laravel (10 and up)

composer require runomine/runomine-php    # or :dev-main from the repository, as above
echo "RUNOMINE_TOKEN=rm_live_..." >> .env

That's all. The package is auto-discovered, adds Runomine\Laravel\TrackPageViews to the web middleware group, and starts recording pageviews. Then track what matters:

use Rom; // or Runomine\Laravel\Facades\Rom

Rom::track('signup');

Rom::track('purchase', ['plan' => 'rent', 'film' => 'the-third-man'], ['amount' => 4.99, 'currency' => 'EUR']);

Inside an HTTP request, Rom::track() attaches the visitor hash, the URL, the country and the logged-in user's id. In queued jobs, commands and webhooks there is no request, so the event goes without a visitor field (never a made-up one): pass the user so runomine can link the event to that person's earlier visits, or hand over the visitor captured during the request with Rom::visitor().

// Your own payment webhook, for payments no connected provider sees (see "Revenue from your payment provider"):
// no browser behind it, but you know who paid.
Rom::track('purchase', ['plan' => 'yearly'], ['amount' => 49, 'currency' => 'EUR'], user: $order->user_id);

// Hand the visitor to a queued job when it should count as the same visit.
ProcessCheckout::dispatch($order, Rom::visitor());
// ...and in the job:
Rom::track('checkout_completed', context: ['visitor' => $this->visitor]);

Page views take props too, stored as the page view's dims: each key becomes a breakdown and a filter in Traffic (area feeds the Areas breakdown). Same rules as events: flat, up to 20 keys, string or number values. With the middleware, a controller sets them on the request; when you record the page view yourself (with middleware => false, or for pages the middleware doesn't see), pass them along:

$request->attributes->set('runomine.props', ['section' => 'news']);              // the middleware sends them

Rom::pageview($request, ['props' => ['section' => 'news', 'area' => 'public']]);  // or by hand

Publish the config if you want to change the defaults:

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

Plain PHP (no framework)

require 'vendor/autoload.php';

$rom = new Runomine\Client(); // reads RUNOMINE_TOKEN, RUNOMINE_URL, RUNOMINE_ENABLED
// or: new Runomine\Client(token: 'rm_live_...');

// In your front controller, for HTML pages:
$rom->pageview([
    'url' => (($_SERVER['HTTPS'] ?? '') === 'on' ? 'https' : 'http').'://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'],
    'referrer' => $_SERVER['HTTP_REFERER'] ?? null,
    'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
    'ip' => $_SERVER['REMOTE_ADDR'],            // hashed here, never sent
    'country' => $_SERVER['HTTP_CF_IPCOUNTRY'] ?? null,
    'user' => $_SESSION['user_id'] ?? null,
    'props' => ['section' => 'news'],         // optional, stored as the page view's dims
]);

// Anywhere:
$rom->track('purchase', ['plan' => 'rent'], ['amount' => 4.99, 'currency' => 'EUR'], user: '42', context: [
    'url' => 'https://example.com/checkout/success',
    'ip' => $_SERVER['REMOTE_ADDR'],
    'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
]);

// Optional: send now. Otherwise it flushes at 500 events, after 5 seconds, on destruct and at shutdown.
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request(); // let the visitor go first
}
$rom->flush();

Only record what the visitor actually saw: GET requests answered 200 with text/html. Long-running workers should call $rom->flush() after each unit of work.

Samples: your app's own numbers on the Live page

From a scheduled command, a few times a minute: queue depth, CPU, people on the site right now. Nested arrays become dotted keys (queue.pending), booleans 0/1, anything that isn't a number is dropped. Sent at once to POST /api/v1/samples (not queued), with the same retries; one sample per second (sending a ts again replaces it), any age.

$ok = Rom::sample(['queue' => ['pending' => 3, 'failed' => 0], 'healthy' => true]);       // or $client->sample(...)
$ok = Rom::sample(['cpu' => 0.12], new DateTimeImmutable('2026-10-14 10:00:00Z'));        // measured earlier

// A catch-up, in calls of at most 500:
$ok = $client->samples([['ts' => '2026-10-14T10:00:00Z', 'data' => ['cpu' => 0.1]], ['ts' => '2026-10-14T10:01:00Z', 'data' => ['cpu' => 0.2]]]);

true means runomine took the call; false that the client is disabled or the call failed after the retries, so a cursor-based push knows not to advance. Never throws.

Revenue from your payment provider

runomine takes revenue from your payment provider, not from prices in your code: connect Stripe, Paddle, Lemon Squeezy, Shopify or PayPal in the site's settings → Integrations, and runomine reads every sale, renewal and refund from it. To attribute a sale to the visit and the person behind it, pass two identifiers when you create the checkout; runomine reads them back from the provider.

Rom::checkoutMetadata();          // ['runomine_visitor' => '5f9e1bebe31e8bf5', 'runomine_user' => '42']
Rom::checkoutMetadata($team->id); // your own id instead of the logged-in user's

// Plain PHP: ip, user agent, host and user
$rom->checkoutMetadata($_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_USER_AGENT'] ?? '', $_SERVER['HTTP_HOST'], $userId);

runomine_visitor is this request's visitor hash and runomine_user your user id, as text. Only keys with a value are returned: outside an HTTP request or without a token there is no visitor (never a made-up one), and with nobody logged in there is no user.

Once a provider is connected, don't also send revenue with Rom::track('purchase', …): it would be counted twice. Send the event without revenue, or not at all. (The webhook examples above are for payments that don't go through a connected provider.)

Stripe Checkout: client_reference_id is used as the user when runomine_user is missing. For subscriptions, also put it on the subscription so renewals carry the user.

$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
]);

// Without Checkout, on the PaymentIntent:
$stripe->paymentIntents->create(['amount' => 499, 'currency' => 'eur', 'metadata' => $metadata]);

Paddle Billing: render the values into the page and pass them as customData to Paddle.js, or as custom_data when you create the transaction through the API.

<script>
    Paddle.Checkout.open({ items: [{ priceId: 'pri_123', quantity: 1 }], customData: @json(Rom::checkoutMetadata()) });
</script>

Lemon Squeezy: checkout_data.custom when you create the checkout through the API, or URL parameters on a checkout link.

'attributes' => ['checkout_data' => ['custom' => Rom::checkoutMetadata()]],

// or a link:
$url = 'https://store.lemonsqueezy.com/checkout/buy/VARIANT?'.http_build_query(['checkout' => ['custom' => Rom::checkoutMetadata()]]);

Shopify (headless, Storefront API cart): cart attributes, which become the order's note attributes.

$attributes = collect(Rom::checkoutMetadata())->map(fn ($value, $key) => ['key' => $key, 'value' => $value])->values();
// cartCreate(input: { lines: [...], attributes: $attributes })

PayPal Orders v2: custom_id on the purchase unit, as a query string (127 characters at most).

'purchase_units' => [[
    'amount' => ['currency_code' => 'EUR', 'value' => '4.99'],
    'custom_id' => http_build_query(Rom::checkoutMetadata()),
]],

What is sent, and privacy

Each event goes to POST https://runomine.com/api/v1/events with your token as a bearer:

field
id A UUID v7 made here. runomine stores an event once even if it's sent twice.
type, name, ts pageview or event, the event's name, and when it happened.
url, referrer The page and where the visitor came from. runomine keeps the path, utm_*/ref and the referrer's host.
user_agent Pageviews only. Used for the bot filter and device/browser/OS families, then discarded.
visitor A daily hash, computed on your server. Required for pageviews (one without an IP or visitor is dropped); left out of events with no request behind them.
country From CF-IPCountry or CloudFront-Viewer-Country, when your CDN sends it.
user Your own user id, as text, when someone is logged in.
revenue, props {amount, currency} in major units, and flat string/number properties.

The IP never leaves your server. The visitor hash is:

salt    = hex(HMAC-SHA256(key = token, message = "runomine:" + UTC date))
visitor = first 16 hex chars of SHA256(salt + "|" + ip + "|" + user_agent + "|" + host)

so a person counts once per day and site, and the hash on its own can't follow anyone across days or sites. If you send user (your own user id), runomine links that user's visits and purchases over time to attribute revenue: that is no longer anonymous audience measurement, and it needs its own legal basis. The hash is the same value runomine's JS beacon produces, so a site using both counts each person once. Runomine\VisitorHash::compute() is public if you need it.

The library does no classification: no source, bot or device logic. runomine does that, once, for every library.

Behind a proxy or CDN

The hash uses $request->ip(), so configure Laravel's trusted proxies ($middleware->trustProxies(at: '*') in bootstrap/app.php when your platform's proxy is the only way in). Without that, every visitor looks like your load balancer. If you are behind Cloudflare and can't configure trusted proxies, set RUNOMINE_IP_HEADER=CF-Connecting-IP (only then: the header is easy to spoof otherwise).

Configuration

Environment variables (both the plain client and Laravel read them):

variable default
RUNOMINE_TOKEN The site's server token (rm_live_...). Required: without it nothing is sent.
RUNOMINE_URL https://runomine.com
RUNOMINE_ENABLED true when the token is set false switches everything off.
RUNOMINE_MIDDLEWARE true Laravel: add the pageview middleware to the web group.
RUNOMINE_IP_HEADER Laravel: read the client IP from this header instead of $request->ip().

config/runomine.php (Laravel):

key default
token, url, enabled from the env
middleware true Set false and add \Runomine\Laravel\TrackPageViews::class yourself where you want it.
skip api/*, up, livewire/*, build/*, storage/*, horizon*, telescope*, pulse*, _debugbar/*, sanctum/* $request->is() patterns never recorded as pageviews. Add your admin, e.g. admin/*.
user null null: the authenticated user's id. false: never send it. Or an invokable class: __invoke(?Request $request): string|int|null.
ip_header null See above.
timeout 2.0 Seconds for the call to runomine (made after the response is sent).

At runtime: Rom::resolveUserUsing(fn (?Request $request) => $request?->user()?->account_id);

The middleware records a pageview when the request is a GET, the answer is 200 with text/html, the path isn't skipped and it isn't a browser prefetch (Sec-Purpose/Purpose: prefetch). It does so in terminate(), after the response has been sent, and flushes there. Events tracked in commands and queued jobs are flushed when the command or job finishes. Inertia visits and other client-side navigations answer JSON, so they aren't seen here; add the beacon for those.

Client options

new Runomine\Client(token:, url:, enabled:, sender:, batchSize: 500, retries: 2, flushInterval: 5.0, timeout: 2.0)

  • Batches are at most 500 events. 5xx and 429 are retried twice (after 0.2 s and 0.4 s), then the batch is dropped. 4xx and network errors are dropped at once. Nothing is ever thrown.
  • sender is any Runomine\Http\Sender (one method: post(url, headers, body): int). The default uses ext-curl.

Testing your app

Laravel: Rom::fake()

use Runomine\Laravel\Facades\Rom;

public function test_buying_tracks_a_purchase(): void
{
    $rom = Rom::fake();

    $this->actingAs($user)->post('/checkout', ['plan' => 'rent']);

    $rom->assertTracked('purchase', fn (array $event) => $event['revenue'] === ['amount' => 4.99, 'currency' => 'EUR']
        && $event['props']['plan'] === 'rent'
        && $event['user'] === (string) $user->id);
    $rom->assertTrackedTimes('purchase', 1);
    $rom->assertNotTracked('refund');
}

public function test_the_home_page_counts(): void
{
    $rom = Rom::fake();

    $this->get('/')->assertOk();

    $rom->assertPageviewRecorded(fn (array $event) => str_ends_with($event['url'], '/'));
}

Also: assertNothingTracked(), assertNoPageviews(), and events(), tracked('name'), pageviews() to inspect the exact payloads that would have been sent (a page view's props are in $event['props']). Nothing leaves the test. To switch runomine off in tests without faking it, set RUNOMINE_ENABLED=false in phpunit.xml.

Samples are recorded too: sampled() lists them as sent (['ts' => …, 'data' => […]]), with assertSampled(fn (array $sample) => ($sample['data']['healthy'] ?? null) === true) and assertNothingSampled().

Plain PHP: FakeSender

$sender = new Runomine\Testing\FakeSender();          // or new FakeSender([503, 202]) to script answers
$client = new Runomine\Client(token: 'rm_live_test', sender: $sender);

$client->track('signup');
$client->flush();

$sender->events();   // the events, as sent
$sender->samples();  // the samples, as sent
$sender->requests(); // url, headers, body of every call

Developing this package

docker run --rm -v "$PWD":/app -w /app composer:2 sh -c "composer install -q && vendor/bin/phpunit"

The tests include the contract's visitor-hash vector, the payload shape, batching, retries, and the Laravel middleware and facade (with Orchestra Testbench).

License

MIT. See LICENSE.