Laravel & PHP

Install StepsKit (Laravel & PHP)

For Laravel with Blade, and for plain PHP apps. Both are multi-page apps, which changes one important thing about identifying users — see step 2.

Using Laravel with Inertia? It behaves like a single-page app; read that section.

Prerequisites

  • Your project API key, from the Integrations page in the dashboard.
  • A layout template included by every page you want tours on.

1. Add the loader

Paste this into your root Blade layout — usually resources/views/layouts/app.blade.php — just before </body>:

<script>
  (function () {
    var stepskit = window.stepskit = window.stepskit || {};
    stepskit._q = stepskit._q || [];

    // Queue API calls made before the SDK finishes loading.
    var methods = ['identify', 'setUserAttributes', 'track', 'refresh', 'playTour', 'stopTour', 'dismissAnnouncement', 'on', 'off', 'destroy', 'validateEnvironment'];
    methods.forEach(function (method) {
      stepskit[method] = stepskit[method] || function () {
        stepskit._q.push([method].concat([].slice.call(arguments)));
      };
    });

    // Load the StepsKit SDK asynchronously.
    var script = document.createElement('script');
    script.async = true;
    script.src = 'https://cdn.stepskit.com/stepskit.latest.js';
    script.setAttribute('data-api-key', 'YOUR_API_KEY');

    var first = document.getElementsByTagName('script')[0];
    first.parentNode.insertBefore(script, first);
  })();
</script>

If your layout defines a script stack, push it there instead so it lands after your other scripts:

{{-- resources/views/layouts/app.blade.php --}}
    @stack('scripts')
  </body>
</html>
{{-- any view --}}
@push('scripts')
  {{-- the StepsKit loader --}}
@endpush

Plain PHP: put the same snippet in whatever shared footer include every page pulls in — footer.php, layout.php, or equivalent. There is nothing Laravel-specific about the snippet itself.

2. Identify the user

This is half the install, not a nice-to-have. Without an id, show-once capping degrades to a per-tab check and any tour with a "filtered" audience is hidden outright.

In a multi-page app, user attributes do not survive a page load. Nothing persists them, so identifying the user on the login page alone does nothing for every page after it. The identify call has to live in the same global layout as the loader, so it runs on every request.

Put it directly after the loader:

{{-- resources/views/layouts/app.blade.php --}}
@auth
  <script>
    stepskit.identify(@json([
      'id' => (string) auth()->id(),
      'email' => auth()->user()->email,
      'plan' => auth()->user()->plan,
    ]));
  </script>
@endauth

Escape it properly

@json() is doing security work here, not formatting work. It encodes the array as JSON and escapes it for a JavaScript context.

Never interpolate user data into a script block with {!! !!}, and don't hand-build the object with {{ }} either{{ }} escapes for HTML, not for JavaScript, so a value containing </script> or a quote can break out of the block. That's a stored XSS, delivered by your own analytics snippet. Use @json() and let it build the whole object.

In plain PHP the equivalent is:

<script>
  stepskit.identify(<?= json_encode([
    'id' => (string) $user->id,
    'email' => $user->email,
  ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
</script>

The JSON_HEX_* flags are what make it safe to emit inside <script>.

Or use data attributes

If you'd rather not emit a second script block, StepsKit reads any data-user-* attribute off the script tag:

<script
  src="https://cdn.stepskit.com/stepskit.latest.js"
  data-api-key="YOUR_API_KEY"
  @auth
    data-user-id="{{ auth()->id() }}"
    data-user-email="{{ auth()->user()->email }}"
    data-user-plan="{{ auth()->user()->plan }}"
  @endauth
  async
></script>

Here {{ }} is correct — these are HTML attribute values, not a JavaScript context. This form skips the queue stub, so guard any later JavaScript calls with window.stepskit?..

Pass whatever your user model already carries; every key becomes targetable and there's no fixed schema to conform to.

3. Verify it worked

Load any page of your app, then:

  1. Dashboard — the project's Integrations page flips to Connected.
  2. Console, on a signed-in page — run window.stepskit.validateEnvironment(). You want a non-null visitorId and a populated userAttributes. A null visitorId means step 2 never ran.
  3. Networkcdn.stepskit.com/stepskit.latest.js returns 200.

validateEnvironment() also returns a toursFiltered table that says exactly why any given tour isn't showing.

Laravel notes

Inertia and Livewire

Inertia turns your Laravel app into a single-page app. Attributes then do survive navigation, and you should identify from the client side instead — see the React or Vue guide depending on your Inertia adapter. The loader still goes in the Blade root layout.

Livewire swaps parts of the DOM in place. If a tour step is anchored to an element inside a Livewire component, that element can be replaced while the tour is open and the step will lose its anchor. Anchor tour steps to stable elements outside the swapped region where you can.

Content Security Policy

Laravel doesn't ship a CSP by default; if you've added one (commonly via spatie/laravel-csp), StepsKit needs:

  • script-src: https://cdn.stepskit.com, plus 'unsafe-inline' for the loader and identify blocks (not needed if you used the data-attribute form)
  • connect-src: https://stepskit.com
  • style-src: 'unsafe-inline'required. The embed injects its entire stylesheet as a <style> element when the script evaluates. Without this the tour engine renders completely unstyled rather than merely off-brand.
  • img-src: https://*.supabase.co — only if your steps use images.

Troubleshooting

Dashboard still says "Not connected". The layout you edited isn't the one that page extends. Check for more than one layout in resources/views/layouts/.

Connected, but tours only work on some pages. Same cause — some routes use a different layout.

Connected, but no tour plays anywhere. Usually identity. Run validateEnvironment(): if visitorId is null, your @auth block isn't rendering, or it's rendering above the loader rather than below it.

Show-once tours reappear on every visit. identify isn't running on every page load. In an MPA it must be in the global layout, not on the login page.

Console syntax error inside the identify block. You interpolated a value without @json(). See above — this is also a security bug, not just a syntax one.

Tour renders as unstyled text. Your CSP is missing style-src 'unsafe-inline'.

Next steps