vTilt
Why vTiltHow It WorksFeaturesFAQDocs
Docs / Vue + PHP
Quick startEvent forwarding
MCP server
Guides
OverviewAuthenticationOAuthAgent skills (prompts)AI intelligenceGoogle Ads
Client setup
CursorClaude DesktopVS CodeCodex
Realtime
Debug ViewRealtime Dashboard
Integration guides
Frontend frameworks
Next.jsNuxt.jsVue.jsReactReact RouterRemixGatsbySvelte / SvelteKitAstroAngularTanStack StartDocusaurus
Backend frameworks
NestJSHonoCloudflare WorkersDjangoFlaskLaravelPhoenixRuby on Rails
Backend languages
PythonPHPRubyElixirGoJava.NET / C#Rust
Stack guides
Vue + PHP
SDK
Browser SDK
InstallScript bundlesEvent trackingAutocaptureIdentify & aliasWeb VitalsSession recordingChat widgetFeature readinessRemote configurationReverse proxyDebug logging
Node SDK
Install & setupCapture, identify & aliasContext & shutdown

Documentation

vTilt
Quick startEvent forwarding

MCP server

Realtime

Debug ViewRealtime Dashboard

Integration guides

Vue + PHP

SDK

DocsIntegration guidesVue + PHP

Vue + PHP

End-to-end vTilt integration for Vue front-end + PHP back-end (Laravel, Symfony, plain PHP), including the identify-on-every-page-load pattern.

This guide shows how to integrate vTilt into a Vue front-end with a PHP back-end (Laravel, Symfony, or plain PHP). It covers the pattern most apps with a server-rendered shell get wrong: identifying users who are already logged in when the page loads.

Important

Important: If identify() is called only inside your Login.vue submit handler, every visitor who arrives with an existing session cookie stays anonymous in vTilt. The fix is to identify on every authenticated page load, not just at login.

#Stack assumptions

  • PHP back-end renders the HTML shell and exposes $currentUser from the session (Laravel auth()->user(), Symfony Security::getUser(), or whatever your app uses).
  • Vue 3 owns the rest of the UI. Could be Inertia, Vue Router (SPA), or progressively-enhanced multi-page (Blade + Vue islands) — the integration shape is the same.
  • vTilt project token is YOUR_PROJECT_TOKEN. Your reverse proxy or vTilt host is https://www.vtilt.com.

#1. Render the current user into the page

The server already knows who the user is — pass that down once, in the HTML shell, before Vue boots. The exact template syntax differs by framework but the output is the same: a single global containing either the user object or null.

#Laravel Blade

{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
  {{-- ... --}}
  <script>
    window.__vtiltContext = {
      token: @json(config('services.vtilt.token')),
      apiHost: @json(config('services.vtilt.host')),
      user: @json(auth()->check() ? [
        'id'    => (string) auth()->id(),
        'email' => auth()->user()->email,
        'name'  => auth()->user()->name,
        'plan'  => auth()->user()->plan,
      ] : null),
    };
  </script>
</head>
<body>
  <div id="app"></div>
  @vite(['resources/js/app.js'])
</body>
</html>
blade

#Symfony Twig

{# templates/base.html.twig #}
<script>
  window.__vtiltContext = {
    token:   {{ vtilt_token|json_encode|raw }},
    apiHost: {{ vtilt_host|json_encode|raw }},
    user:    {{ (app.user
      ? { id: app.user.id|json_encode, email: app.user.email, name: app.user.name, plan: app.user.plan }
      : null)|json_encode|raw }},
  };
</script>
twig

#Plain PHP

<script>
  window.__vtiltContext = <?= json_encode([
    'token'   => $vtiltToken,
    'apiHost' => $vtiltHost,
    'user'    => $currentUser
      ? [
          'id'    => (string) $currentUser->id,
          'email' => $currentUser->email,
          'name'  => $currentUser->name,
          'plan'  => $currentUser->plan,
      ]
      : null,
  ], JSON_THROW_ON_ERROR) ?>;
</script>
php

Note

Note: Always cast user ids to strings when serialising. JavaScript doesn't safely round-trip integers larger than 2^53. If your ids are auto-increment integers this will eventually bite you.

#2. Initialise vTilt once, in your Vue entry

In your main.js / main.ts (or whichever file boots Vue), import and init the SDK before mounting the app. Then identify the user from the context blob.

// resources/js/main.ts
import { createApp } from 'vue'
import { vt } from '@v-tilt/browser'
import App from './App.vue'
import { router } from './router' // optional, if SPA
import { installVtilt } from './vtilt'

const ctx = window.__vtiltContext

vt.init(ctx.token, {
  api_host: ctx.apiHost,
  autocapture: true,
  capture_pageview: true,
  capture_pageleave: true,
})

if (ctx.user) {
  vt.identify(ctx.user.id, {
    email: ctx.user.email,
    name: ctx.user.name,
    plan: ctx.user.plan,
  })
}

const app = createApp(App)
installVtilt(app, router)
app.mount('#app')
typescript

Now every authenticated visitor is identified on first paint, on every page load. Anonymous visitors stay anonymous until they sign up or log in.

#3. Track SPA route changes

If you use Vue Router for client-side navigation, the SDK doesn't see those automatically — it only sees the initial server-rendered URL. Hook the router and emit a pageview on every successful navigation.

// resources/js/vtilt.ts
import type { App } from 'vue'
import type { Router } from 'vue-router'
import { vt } from '@v-tilt/browser'

export function installVtilt(app: App, router?: Router) {
  if (router) {
    router.afterEach((to, from, failure) => {
      if (failure) return
      vt.capture('$pageview', {
        $current_url: window.location.href,
        $pathname: to.fullPath,
        $referrer: from.fullPath,
      })
    })
  }

  // Expose `vt` to templates / `useVt()` composables if you want
  app.config.globalProperties.$vt = vt
  app.provide('vt', vt)
}
typescript

Tip

Tip: With the script-tag snippet (no bundler), the SDK monkey-patches pushState/replaceState and listens for popstate, so SPA route changes work automatically. The manual afterEach above is needed only when you bypass the standard History API in a custom router.

#4. Login, signup and logout flows

Augment your auth handlers so client-side state updates also tell vTilt. The page-load identify already covers most cases — these calls handle the moments where the page-load context isn't fresh yet.

<!-- resources/js/Pages/Login.vue -->
<script setup lang="ts">
import { vt } from '@v-tilt/browser'

async function onSubmit(form) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    body: JSON.stringify(form),
    headers: { 'Content-Type': 'application/json' },
  })
  const { user } = await res.json()

  vt.identify(user.id, {
    email: user.email,
    name: user.name,
    plan: user.plan,
  })
  vt.capture('user_logged_in', { method: 'password' })

  window.location.href = '/dashboard'
}
</script>
vue
<!-- resources/js/Pages/Signup.vue -->
<script setup lang="ts">
import { vt } from '@v-tilt/browser'

async function onSubmit(form) {
  const res = await fetch('/api/auth/signup', {
    method: 'POST',
    body: JSON.stringify(form),
    headers: { 'Content-Type': 'application/json' },
  })
  const { user } = await res.json()

  // alias() preserves the user's pre-signup browsing in their new person record
  vt.alias(user.id)
  vt.identify(user.id, { email: user.email, plan: 'free' })
  vt.capture('user_signed_up', { method: 'email' })

  window.location.href = '/onboarding'
}
</script>
vue
<!-- LogoutButton.vue -->
<script setup lang="ts">
import { vt } from '@v-tilt/browser'

async function onLogout() {
  vt.capture('user_logged_out')
  vt.resetUser()
  await fetch('/api/auth/logout', { method: 'POST' })
  window.location.href = '/'
}
</script>
vue

#5. (Recommended) Server-side events from PHP

Some events are best emitted from PHP — webhook handlers, scheduled jobs, server-side conversions, or any place a redirect kills the browser session before the SDK can flush. Use a tiny HTTP helper since there's no official PHP SDK; the wire format is identical to @v-tilt/node.

function vtilt_capture(string $token, string $apiHost, array $event): void
{
    $payload = [
        'api_key' => $token,
        'event'   => $event['event'],
        'distinct_id' => $event['distinctId'],
        'properties' => $event['properties'] ?? [],
        'timestamp' => $event['timestamp'] ?? gmdate('c'),
    ];

    $ch = curl_init("$apiHost/api/e");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_THROW_ON_ERROR),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 2,
    ]);
    curl_exec($ch);
    curl_close($ch);
}

vtilt_capture(
    config('services.vtilt.token'),
    config('services.vtilt.host'),
    [
        'event'      => 'subscription_renewed',
        'distinctId' => (string) $user->id,
        'properties' => [
            'plan'   => $user->plan,
            'amount' => 99.0,
        ],
    ],
);
php

Tip

Tip: Run server-side captures inside an after-response queue (Laravel's dispatch(), Symfony Messenger) so analytics never blocks the user-facing response. Network failures shouldn't surface to your users.

#6. Verify the integration

  1. Open your app in an incognito window — you're now an anonymous visitor.
  2. Open the Debug View in another tab. You should see a $pageview event arrive within ~1 second.
  3. Sign in. The next event should be $identify with your user id, followed by user_logged_in.
  4. Refresh the page while signed in. You should see another $identify immediately — this is the per-page-load identify confirming the integration. The Debug View shows it as a no-op merge against the same id.
  5. Click "Logout". You should see user_logged_out, then later events should drop back to a fresh anonymous $device_id.

If step 4 doesn't fire, your page-load identify isn't running. Common causes:

  • The PHP template renders __vtiltContext.user = null even when the user is authenticated. Check your auth helper.
  • The identify() call is wrapped in a Vue lifecycle hook that runs before init; move it directly under vt.init() in main.ts.
  • The user object exists but user.id is missing. Always send the canonical user id, never the email.

#Checklist

  • PHP renders window.__vtiltContext with { token, apiHost, user } in the shell template.
  • Vue entry calls vt.init(...) once with the token from context.
  • Vue entry calls vt.identify(user.id, ...) immediately after init when user is non-null.
  • Login / signup handlers call vt.identify (and vt.alias on signup).
  • Logout handler calls vt.resetUser.
  • SPA navigations (if any) emit $pageview from the router's afterEach.
  • Server-side events are sent over HTTP from PHP for the moments the browser misses.

Once these are in place, the Persons view in the dashboard reflects real authenticated users, not orphaned anonymous device ids.

PreviousRustIntegration guidesNextInstallBrowser SDK

On this page

  • Stack assumptions
  • 1. Render the current user into the page
  • Laravel Blade
  • Symfony Twig
  • Plain PHP
  • 2. Initialise vTilt once, in your Vue entry
  • 3. Track SPA route changes
  • 4. Login, signup and logout flows
  • 5. (Recommended) Server-side events from PHP
  • 6. Verify the integration
  • Checklist