Laravel Proxy Setup
Configure Laravel routes and middleware to proxy Rybbit tracking
Laravel makes it straightforward to proxy Rybbit tracking using routes and the HTTP client. This guide shows how to set up proxy endpoints in your Laravel application.
Two settings make or break a first-party proxy:
- Enable First-Party Proxy in your Rybbit Site Settings → Privacy & Security, so the visitor IP your proxy forwards in
X-Forwarded-Foris trusted directly. - Forward all of the browser's request headers, not a hand-picked few. Rybbit's bot detection expects the headers every real browser sends (
Accept,Accept-Language,Accept-Encoding,Sec-Fetch-*) — proxies that forward onlyUser-Agentget their events accepted with200 OKbut classified as bot traffic. UseX-Forwarded-Forfor the visitor IP;X-Real-IPdoes not survive Rybbit Cloud's edge.
Overview
Laravel's HTTP client (built on Guzzle) provides an elegant way to proxy requests to Rybbit servers while maintaining full control over headers, caching, and error handling.
What you'll achieve:
- Proxy all Rybbit endpoints through your Laravel app
- Forward necessary headers for accurate tracking
- Optional caching with Laravel Cache
- Support all Rybbit features
Prerequisites
- Laravel 8 or later
- Your Rybbit instance URL:
- Cloud hosted:
https://app.rybbit.io - Self-hosted: Your instance URL
- Cloud hosted:
- Your Rybbit site ID
Implementation
Configure Environment Variables
Add your Rybbit host to .env:
# .env
RYBBIT_HOST=https://app.rybbit.io
# For self-hosted: RYBBIT_HOST=https://analytics.yourcompany.comCreate Analytics Controller
Generate a controller for handling analytics proxying:
php artisan make:controller AnalyticsProxyControllerThen implement the proxy logic:
<?php
// app/Http/Controllers/AnalyticsProxyController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AnalyticsProxyController extends Controller
{
// Headers that describe the hop to your app, not the visitor's browser,
// plus credentials (e.g. HTTP basic auth on a staging site) Rybbit must not get.
private const SKIP_HEADERS = [
'host', 'content-length', 'connection', 'cookie', 'keep-alive',
'transfer-encoding', 'upgrade', 'x-forwarded-for', 'x-forwarded-host',
'x-forwarded-proto', 'x-forwarded-port', 'x-real-ip',
'authorization', 'proxy-authorization',
];
private string $rybbitHost;
public function __construct()
{
$this->rybbitHost = rtrim(config('services.rybbit.host', 'https://app.rybbit.io'), '/');
}
/**
* Scripts (GET), cached for 1 hour
*/
public function proxyScript(Request $request, string $script)
{
return $this->cached("rybbit_script_{$script}", 3600, "api/{$script}", $request);
}
/**
* Site tracking config (GET), cached for 5 minutes
*/
public function proxySiteConfig(Request $request, string $siteId)
{
return $this->cached("rybbit_config_{$siteId}", 300, "api/site/tracking-config/{$siteId}", $request);
}
/**
* Every POST endpoint: track, identify, feature flags, session replay
*/
public function proxyPost(Request $request)
{
// Strip the leading "analytics/" and forward to the same path under /api
$path = 'api/' . substr($request->path(), strlen('analytics/'));
return $this->forward('POST', $path, $request);
}
private function cached(string $key, int $ttl, string $path, Request $request)
{
$cached = Cache::get($key);
if ($cached === null) {
$response = $this->forward('GET', $path, $request);
if ($response->getStatusCode() !== 200) {
return $response;
}
$cached = [
'body' => $response->getContent(),
'type' => $response->headers->get('Content-Type'),
];
Cache::put($key, $cached, $ttl);
}
return response($cached['body'], 200)->header('Content-Type', $cached['type']);
}
private function forward(string $method, string $path, Request $request)
{
$url = "{$this->rybbitHost}/{$path}";
// Pass the browser's headers through; Rybbit's bot detection expects them.
$headers = collect($request->headers->all())
->reject(fn ($value, $name) => in_array(strtolower($name), self::SKIP_HEADERS, true))
->map(fn ($values) => implode(', ', $values))
->all();
// The visitor IP. $request->ip() honors your TrustProxies settings.
$headers['X-Forwarded-For'] = $request->ip();
// Replace the copied Accept-Encoding (the keys above are lowercase) with
// gzip, which the HTTP client can decode. Send it only if the browser
// offered gzip: bot detection flags clients that don't.
$headers['accept-encoding'] = str_contains($request->header('Accept-Encoding', ''), 'gzip') ? 'gzip' : 'identity';
$http = Http::timeout(30)->withHeaders($headers);
try {
$response = $method === 'POST'
// Forward the raw body. $request->all() would pass through
// ConvertEmptyStringsToNull, and Rybbit rejects null fields.
? $http->withBody($request->getContent(), $request->header('Content-Type', 'application/json'))->post($url)
: $http->get($url);
return response($response->body(), $response->status())
->header('Content-Type', $response->header('Content-Type') ?: 'text/plain');
} catch (\Throwable $e) {
Log::error('Rybbit proxy error', ['url' => $url, 'error' => $e->getMessage()]);
return response()->json(['error' => 'Analytics proxy error'], 502);
}
}
}Add Routes
Add routes for the analytics proxy in routes/web.php:
<?php
// routes/web.php
use App\Http\Controllers\AnalyticsProxyController;
// Analytics proxy routes
Route::prefix('analytics')->group(function () {
// Scripts (GET). Web vitals are bundled into script.js;
// replay.js loads only when session replay is on.
Route::get('/{script}', [AnalyticsProxyController::class, 'proxyScript'])
->where('script', '(script|replay)\.js');
// Configuration (GET)
Route::get('/site/tracking-config/{siteId}', [AnalyticsProxyController::class, 'proxySiteConfig']);
// Tracking endpoints (POST)
Route::post('/track', [AnalyticsProxyController::class, 'proxyPost']);
Route::post('/identify', [AnalyticsProxyController::class, 'proxyPost']);
Route::post('/site/{siteId}/feature-flags/evaluate', [AnalyticsProxyController::class, 'proxyPost']);
Route::post('/session-replay/record/{siteId}', [AnalyticsProxyController::class, 'proxyPost']);
});These routes cover every endpoint the tracking script calls. If the feature flag route is missing, rybbit.flag() silently returns your fallback.
Keep the proxied file named script.js. The script cuts its own URL at /script.js to find where to send events, so a renamed file (for example /analytics/a.js) sends every request to the wrong place.
Exclude the Routes from CSRF Protection
Routes in routes/web.php run Laravel's CSRF check, which rejects the tracker's POSTs with a 419. Exclude the analytics paths.
<?php
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
// ...
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'analytics/*',
]);
})
// ...
->create();// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'analytics/*',
];Add Service Configuration (Optional)
Add Rybbit configuration to config/services.php:
<?php
// config/services.php
return [
// ... other services
'rybbit' => [
'host' => env('RYBBIT_HOST', 'https://app.rybbit.io'),
],
];Update Your Blade Templates
Add the tracking script to your layout:
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ config('app.name') }}</title>
{{-- Rybbit Analytics --}}
<script src="{{ url('/analytics/script.js') }}?siteId=YOUR_SITE_ID" defer></script>
</head>
<body>
@yield('content')
</body>
</html>Older snippets that pass the ID as data-site-id still work.
Verify the Setup
-
Clear route cache (if caching enabled):
php artisan route:clear -
Visit your application with Developer Tools open
-
Check Network tab: Requests should go to
/analytics/* -
Verify in Rybbit dashboard: Data should appear
How It Works
Laravel routes intercept requests to /analytics/* and forward them to Rybbit:
- Request to
/analytics/script.jshits Laravel route - Controller forwards to
https://app.rybbit.io/api/script.js - Response is cached (for cacheable endpoints)
- The browser's headers are passed through, and the visitor IP goes in
X-Forwarded-For
Advanced Configuration
Rate Limiting
Laravel's built-in throttle middleware limits requests per visitor IP. For example, 100 requests per minute:
Route::prefix('analytics')->middleware('throttle:100,1')->group(function () {
// ... routes
});Session replay sends batches every few seconds while recording, so don't set the limit so low that active visitors hit it.
Custom Cache Configuration
Use different cache drivers for analytics:
private function cached(string $key, int $ttl, string $path, Request $request)
{
// Use Redis for analytics caching
$cache = Cache::store('redis');
// Or use file cache
// $cache = Cache::store('file');
// ... rest of the method, calling $cache->get() / $cache->put()
}Don't Queue Tracking Requests
Forward every analytics request synchronously and return Rybbit's response. Don't hand session replay, /track or /identify requests to a queued job that answers right away:
- The tracker reads the replay endpoint's response. Rybbit uses it to tell the tracker to stop recording (when a session hits the replay size cap) or to take a fresh full snapshot. A queued proxy that always answers
queuedhides both. - A job runs outside the request, so it no longer has the browser's headers. Batches forwarded with only an IP and user agent are classified as bot traffic.
CORS Configuration
If serving from a different domain:
<?php
// config/cors.php
return [
'paths' => ['analytics/*'],
'allowed_methods' => ['GET', 'POST'],
'allowed_origins' => ['https://yourdomain.com'],
'allowed_headers' => ['Content-Type', 'X-Requested-With'],
];Troubleshooting
404 Not Found
Problem: Routes return 404.
Solution:
- Clear route cache:
php artisan route:clear - List routes to verify:
php artisan route:list --path=analytics - Check route order (more specific routes first)
Session errors with POST requests
Problem: CSRF token errors on tracking endpoints.
Solution:
Exclude analytics/* from CSRF protection. See Exclude the Routes from CSRF Protection for Laravel 11+ (bootstrap/app.php) and earlier versions.
Incorrect geolocation
Problem: All visitors show server's location.
Solution:
- Enable First-Party Proxy in Site Settings → Privacy & Security.
- Make sure the controller sets
X-Forwarded-Forto$request->ip(). - If your app runs behind a load balancer or CDN, configure Laravel's trusted proxies so
$request->ip()returns the visitor IP rather than the load balancer's.
Events return 200 but don't appear
Problem: /analytics/track answers 200, but the dashboard stays empty.
Solution:
Check that the controller passes the browser's headers through. A proxy that sends only User-Agent and the IP looks like a script, and its events are classified as bot traffic. Also check that POST bodies are forwarded raw with $request->getContent(): re-encoding $request->all() turns empty strings into null, which Rybbit rejects with a 400.
Cache not clearing
Problem: Old script cached after Rybbit update.
Solution: Clear specific cache key:
php artisan cache:forget rybbit_script_script.jsOr clear all cache:
php artisan cache:clearPerformance Optimization
Cache Optimization
// Configure cache tags for easy clearing
Cache::tags(['rybbit', 'scripts'])->remember($cacheKey, 3600, function () {
// ... fetch script
});
// Clear all Rybbit cache
Cache::tags(['rybbit'])->flush();HTTP Client Optimization
// Use connection pooling
Http::pool(fn (Pool $pool) => [
$pool->get("{$this->rybbitHost}/api/script.js"),
$pool->get("{$this->rybbitHost}/api/replay.js"),
]);Related Resources
- Laravel Integration Guide - General Laravel integration
- Tracking Script Documentation - Script configuration
- Laravel HTTP Client - Official Laravel docs