Most analytics tools can tell you that someone visited /pricing and that someone else created an account.
The useful question is whether they were the same person.
I wanted to answer questinos like these in a Laravel application:
- Which landing page led to a real signup?
- Did the visitor read the documentation before registering?
- Was the first visit from search, a referral, or a campaign?
- Which pages appaer in successful journeys?
I did not need cross-site tracking, advertising profiles, or a full recording of everything a visitor did. I also did not want a third-party analytics script controlling the data model.
So I built a small first-party journey tracker. Public pages send a deliberately limited page-view beacon. When the visitor later signs in, the application links the recent anonymous journey to the authenticated account.
This article explains the design, the parts I intentionally left out, and one important warning: not using cookies does not make analytics consent-free or automatically privacy-compliant.
The model: two random IDs
The browser keeps two opaque identifiers:
visitor_id -> persists between visits
session_id -> lasts for the current browser session
The visitor ID lives in localStorage. The session ID lives in sessionStorage.
Neither contains an email address, database ID, IP address, or encoded user information. They are simply random strings used to join events.
Here is a simplified version:
function randomId() {
return crypto.randomUUID().replaceAll('-', '').slice(0, 24);
}
function storedId(storage, key) {
let value = storage.getItem(key);
if (!/^[a-z0-9]{24}$/.test(value || '')) {
value = randomId();
storage.setItem(key, value);
}
return value;
}
const existingSession = sessionStorage.getItem('journey_session');
const visitorId = storedId(localStorage, 'journey_visitor');
const sessionId = storedId(sessionStorage, 'journey_session');
const isEntry = existingSession === null;
localStorage lets the browser recognize a later visit without adding a cookie to every HTTP request. sessionStorage gives each browsing session a separate boundary.
That distinction matters. A person may first arrive from search, return from a newsletter a week later, and finally register. I want both the first-touch source and the most recent acquisition source, not one value constantly overwriting the other.
Send less data than you can
The browser knows far more than this system needs. The easiest way to reduce privacy and security risk is not to collect unnecessary fields in the first place.
My page-view payload contains:
- The two random IDs
- The page path and route name
- The page title
- Whether this is the first view in the session
- The external referrer host and path
- An allowlist of standard UTM parameters
It does not contain:
- IP addresses stored by the application
- Full user-agent strings
- Browser fingerprints
- Full URLs with arbitrary query strings
- Email addresses or account details on anonymous requests
The query-string rule is particularly important. URLs often contain password-reset tokens, checkout session IDs, email addresses, search terms, and other values that should never be copied into an analytics table by accident.
Instead of sending location.href, I send location.pathname and explicitly select the campaign parameters I understand:
const query = new URLSearchParams(location.search);
const data = new FormData();
data.append('v', visitorId);
data.append('s', sessionId);
data.append('e', isEntry ? '1' : '0');
data.append('p', location.pathname);
data.append('t', document.title);
data.append('us', query.get('utm_source') || '');
data.append('um', query.get('utm_medium') || '');
data.append('uc', query.get('utm_campaign') || '');
if (document.referrer) {
const referrer = new URL(document.referrer);
data.append('rh', referrer.hostname);
data.append('rp', referrer.pathname);
}
navigator.sendBeacon('/analytics/hit', data);
sendBeacon() works well here because the browser can transmit the small payload without delaying navigation. The endpoint returns 204 No Content and never needs to render anything.
Keep public page views stateless
My public marketing pages are edge-cacheable. Creating a Laravel session for every anonymous page view would add a Set-Cookie header and make that caching model harder to reason about.
The page-view endpoint therefore sits outside the session middleware group. It accepts the random browser identifiers but does not start a Laravel session.
Route::post('/analytics/hit', AnalyticsHitController::class)
->middleware('throttle:analytics')
->name('analytics.hit');
The route is rate-limited independently from login and contact forms. Analytics traffic should not consume a visitor's allowance for an unrelated feature.
The server validates every field even though the script generated it:
private function validId(string $id): bool
{
return preg_match('/^[a-z0-9]{24}$/', $id) === 1;
}
private function cleanPath(string $value): string
{
$withoutFragment = explode('#', trim($value), 2)[0];
$path = explode('?', $withoutFragment, 2)[0];
return mb_substr(
str_starts_with($path, '/') ? $path : '/',
0,
191,
);
}
Client-side filtering is a convenience. Server-side filtering is the boundary.
I also normalize referrer hosts, reject malformed values, cap string lengths, and treat redirects from authentication and payment providers as part of the existing journey rather than a new acquisition.
Two tables are enough
The first table stores one row per random visitor:
Schema::create('analytics_visitors', function (Blueprint $table) {
$table->id();
$table->char('visitor_id', 24)->unique();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->timestamp('first_seen_at');
$table->timestamp('last_seen_at');
$table->string('first_landing_path', 191)->nullable();
$table->string('first_source', 96)->nullable();
$table->string('first_medium', 48)->nullable();
$table->timestamp('last_touch_at')->nullable();
$table->string('last_landing_path', 191)->nullable();
$table->string('last_source', 96)->nullable();
$table->string('last_medium', 48)->nullable();
});
The second table is the event stream:
Schema::create('analytics_page_views', function (Blueprint $table) {
$table->id();
$table->char('visitor_id', 24);
$table->char('session_id', 24);
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('path', 191);
$table->string('route', 96)->nullable();
$table->boolean('is_entry')->default(false);
$table->string('source', 96)->nullable();
$table->string('medium', 48)->nullable();
$table->string('campaign', 191)->nullable();
$table->timestamp('viewed_at');
$table->index(['visitor_id', 'viewed_at']);
$table->index(['session_id', 'viewed_at']);
$table->index(['user_id', 'viewed_at']);
});
The visitor row makes acquisition reports cheap. The page-view rows preserve the actual sequence.
I keep first touch and last touch separate. First touch answers "How did this person originally find the site?" Last touch answers "What brought them back before conversion?" Mixing the two produces confident-looking reports that answer neither question well.
Link the journey only after authentication
Anonymous page views keep user_id as null. An authenticated page includes a second beacon to a session-protected route:
Route::post('/analytics/identify', AnalyticsIdentifyController::class)
->middleware('auth')
->name('analytics.identify');
The browser sends the same visitor and session IDs, plus the normal CSRF token. The server gets the account from the authenticated Laravel session. It never accepts a user ID supplied by JavaScript.
DB::transaction(function () use ($request, $visitorId, $sessionId) {
$now = now('UTC');
$userId = $request->user()->id;
DB::table('analytics_visitors')
->where('visitor_id', $visitorId)
->update([
'user_id' => $userId,
'last_seen_at' => $now,
]);
DB::table('analytics_page_views')
->where('visitor_id', $visitorId)
->whereNull('user_id')
->where('viewed_at', '>=', $now->copy()->subDays(30))
->update(['user_id' => $userId]);
DB::table('analytics_page_views')
->where('visitor_id', $visitorId)
->where('session_id', $sessionId)
->update(['user_id' => $userId]);
});
The 30-day boundary is deliberate. A permanent visitor ID on a shared computer is weak evidence that activity from six months ago belongs to the person signing in today.
Even within 30 days, this association is best-effort attribution, not identity proof. I would never use it for authorization, fraud decisions, security alerts, or anything that materially affects a person.
For a product with frequent shared-device use, I would avoid retroactive visitor-level linking entirely or introduce an explicit visitor_user_links table with time ranges. The data model should admit uncertainty rather than hide it.
Handle race conditions explicitly
The identify request and the first page-view beacon can arrive in either order.
If identification arrives first, the visitor row may exist before its acquisition fields are populated. If the page view arrives first, it initially has no user ID.
The two endpoints therefore need to be idempotent:
- Both may create the visitor row with
insertOrIgnore. - The hit endpoint fills first-touch fields only when they are still empty.
- The identify endpoint backfills recent anonymous views.
- New page views copy the user ID already associated with the visitor.
- Updates run inside transactions where ordering matters.
This is the kind of race that almost never appears in manual testing and eventually appears in production.
Retention is part of the feature
Analytics tables quietly become permanent archives unless deletion is designed from the start.
I use a scheduled task to delete old page views and remove stale anonymous visitor rows:
Schedule::call(function () {
$cutoff = now('UTC')->subDays(400);
DB::table('analytics_page_views')
->where('viewed_at', '<', $cutoff)
->delete();
DB::table('analytics_visitors')
->whereNull('user_id')
->where('last_seen_at', '<', $cutoff)
->delete();
})->daily();
The correct duration depends on the product and the questions the data is meant to answer. The important part is choosing a duration rather than inheriting "forever" as an accidental default.
Deletion and access workflows should also account for journey data once it has been associated with a user.
Cookie-free does not mean consent-free
This architecture avoids third-party cookies and third-party analytics scripts. It does not avoid browser storage: localStorage and sessionStorage are storage technologies too.
Rules vary by jurisdiction and purpose. For example, the UK Information Commissioner's Office states that storage-and-access rules can apply to web storage, including localStorage, whether it is used in a first-party or third-party context.
Reference: ICO guidance on storage and access technologies
So "we do not use cookies" is not a legal strategy. Document what is stored, why it is stored, how long it lasts, whether consent is required, and how a person can exercise applicable choices and rights. Get advice appropriate to the regions you serve.
Privacy-conscious engineering is not about finding a different browser API and declaring victory. It is about purpose limitation, data minimization, retention, access control, and honest communication.
What this small system gives me
With two identifiers, two tables, and two endpoints, I can reconstruct a journey like this:
organic search
-> landing page
-> pricing
-> documentation
-> sign in
-> dashboard
I can connect acquisition to real product outcomes without sending browsing data to an advertising network or copying sensitive query strings into a generic analytics platform.
The system is intentionally boring. It does not record the mouse, fingerprint the browser, or guess who somebody is across devices. It collects enough information to answer a small set of product questions and stops there.
That constraint is the feature.
Disclosure: This article was written with AI assistance and edited from a real Laravel implementation.
Top comments (0)