wappomic / laravel-analytics
Cookie-free GDPR-compliant analytics package for Laravel - API-based without database
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- laravel/framework: ^12.0
Requires (Dev)
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2025-08-12 16:03:25 UTC
README
Cookie-free, GDPR-compliant analytics package for Laravel
Collects anonymized website data and sends it to your own API. No cookies, no banner - just install and go.
π― Features
- πͺ Cookie-free - No consent required
- π GDPR-compliant - Immediate anonymization of all data
- π API-based - Sends data to your own analytics API
- β‘ Performance - < 2ms overhead, asynchronous processing
- ποΈ Multi-App Support - One dashboard for multiple apps/websites
- π§ Plug & Play - Automatic tracking after installation
- π Session Tracking - Cookie-free unique visitor identification
π¦ Installation
composer require wappomic/laravel-analytics php artisan vendor:publish --tag=analytics-config
.env Configuration
# REQUIRED ANALYTICS_API_URL=https://your-dashboard.com/api/analytics ANALYTICS_API_KEY=your-unique-app-key-12345 # OPTIONAL ANALYTICS_APP_NAME="My Laravel Shop" ANALYTICS_ENABLED=true ANALYTICS_QUEUE_ENABLED=true ANALYTICS_QUEUE_CONNECTION=redis ANALYTICS_QUEUE_NAME=analytics ANALYTICS_SESSION_TRACKING_ENABLED=true ANALYTICS_SESSION_TTL_HOURS=24 ANALYTICS_VERBOSE_LOGGING=0 # Use 1 for true, 0 for false
That's it! π The package now automatically tracks all web requests.
π Changelog
All notable changes to this project are documented in the CHANGELOG.md.
π Current Version: v1.0.4
- π‘οΈ Production Hardening: Request deduplication prevents 3x tracking issues
- π Enhanced Diagnostics: Detailed API error logging with HTTP response details
- π§ Smart Filtering: Load balancer health check detection and internal request filtering
- π Flexible Logging: Optional verbose logging with
ANALYTICS_VERBOSE_LOGGING
config
π« Excluded Routes Configuration
By default, certain routes are automatically excluded from tracking (admin pages, APIs, static files, etc.). You can customize this list or disable exclusions entirely.
Default Excluded Routes
// config/analytics.php 'excluded_routes' => [ '/admin*', '/api*', '/broadcasting*', // π― Solves Laravel Broadcasting auth issues '/health*', '/robots.txt', '/sitemap.xml', '*.json', '*.xml', '*.css', '*.js', '*.ico', '*.png', '*.jpg', '*.jpeg', '*.gif', '*.svg', '*.woff*', '*.ttf', ],
Customization Examples
// Track everything (no exclusions) 'excluded_routes' => [], // Only exclude specific routes 'excluded_routes' => ['/admin*', '/broadcasting*'], // Add your custom routes to defaults 'excluded_routes' => [ '/admin*', '/api*', '/broadcasting*', '/health*', '/robots.txt', '/sitemap.xml', '*.json', '*.xml', '*.css', '*.js', '*.ico', '*.png', '*.jpg', '*.jpeg', '*.gif', '*.svg', '*.woff*', '*.ttf', '/my-private-section*', // Your custom exclusions '/internal-api/*', '*.pdf', ],
Wildcard Patterns
The excluded routes support wildcard patterns using fnmatch()
:
Pattern | Matches | Examples |
---|---|---|
/admin* |
Routes starting with /admin |
/admin , /admin/users , /admin/dashboard/settings |
*.json |
Routes ending with .json |
/data.json , /api/users.json |
/api/* |
Routes under /api/ |
/api/users , /api/v1/posts |
*broadcasting* |
Routes containing broadcasting |
/broadcasting/auth , /laravel/broadcasting/auth |
Common Use Cases
Laravel Broadcasting (WebSocket Auth):
'excluded_routes' => ['/broadcasting*'], // Prevents tracking of '/broadcasting/auth' routes
SPA Applications:
'excluded_routes' => ['/api*', '*.json'], // Only track page views, not API calls
Track Everything:
'excluded_routes' => [], // No automatic exclusions - track all routes
Combined with Route Middleware
You have two options to exclude routes:
// Option 1: Global config (recommended) 'excluded_routes' => ['/admin*', '/broadcasting*'], // Option 2: Per-route basis Route::get('/admin', AdminController::class)->withoutMiddleware('analytics.tracking');
π Data Format
Your API receives POST requests with this JSON payload:
{ "api_key": "your-unique-app-key-12345", "app_name": "My Laravel Shop", "timestamp": "2025-08-04T14:00:00Z", "url": "/products/laptop", "referrer": "https://google.com", "anonymized_ip": "192.168.1.0", "browser": "Chrome", "device": "desktop", "country": "DE", "session_hash": "abc123def456789abcdef123456789abc", "is_new_session": true, "pageview_count": 1, "session_duration": 0, "custom_data": null }
π Multi-App Setup (Recommended)
One dashboard for all your apps:
# App 1: Online Shop ANALYTICS_API_KEY=shop-key-abc123 ANALYTICS_APP_NAME="Online Shop" # App 2: Blog ANALYTICS_API_KEY=blog-key-def456 ANALYTICS_APP_NAME="Tech Blog" # App 3: Landing Page ANALYTICS_API_KEY=landing-key-ghi789 ANALYTICS_APP_NAME="Product Landing"
All apps send to the same ANALYTICS_API_URL
but with different api_key
- perfect data separation.
π οΈ Analytics Dashboard Implementation
1. Create API Endpoint
// routes/api.php Route::post('/analytics', [AnalyticsController::class, 'store']);
// app/Http/Controllers/AnalyticsController.php <?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Models\AnalyticsData; use App\Models\App; class AnalyticsController extends Controller { public function store(Request $request) { $data = $request->validate([ 'api_key' => 'required|string', 'app_name' => 'nullable|string', 'timestamp' => 'required|date', 'url' => 'required|string', 'referrer' => 'nullable|string', 'anonymized_ip' => 'required|string', 'browser' => 'nullable|string', 'device' => 'nullable|string', 'country' => 'nullable|string|size:2', 'session_hash' => 'nullable|string', 'is_new_session' => 'nullable|boolean', 'pageview_count' => 'nullable|integer', 'session_duration' => 'nullable|integer', 'custom_data' => 'nullable|array', ]); // Find app by API key $app = App::where('api_key', $data['api_key'])->first(); if (!$app) { return response()->json(['error' => 'Invalid API key'], 401); } // Store analytics data AnalyticsData::create([ 'app_id' => $app->id, 'timestamp' => $data['timestamp'], 'url' => $data['url'], 'referrer' => $data['referrer'], 'anonymized_ip' => $data['anonymized_ip'], 'browser' => $data['browser'], 'device' => $data['device'], 'country' => $data['country'], 'session_hash' => $data['session_hash'] ?? null, 'is_new_session' => $data['is_new_session'] ?? false, 'pageview_count' => $data['pageview_count'] ?? 1, 'session_duration' => $data['session_duration'] ?? 0, 'custom_data' => $data['custom_data'], ]); // Update app name on first request (optional) if ($data['app_name'] && $app->name !== $data['app_name']) { $app->update(['name' => $data['app_name']]); } return response()->json(['status' => 'success']); } }
2. Database Schema
// Migration: create_apps_table.php Schema::create('apps', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('api_key')->unique(); $table->string('domain')->nullable(); $table->timestamps(); }); // Migration: create_analytics_data_table.php Schema::create('analytics_data', function (Blueprint $table) { $table->id(); $table->foreignId('app_id')->constrained()->onDelete('cascade'); $table->timestamp('timestamp'); $table->string('url'); $table->string('referrer')->nullable(); $table->string('anonymized_ip'); $table->string('browser')->nullable(); $table->string('device')->nullable(); $table->string('country', 2)->nullable(); $table->string('session_hash', 64)->nullable(); $table->boolean('is_new_session')->default(false); $table->integer('pageview_count')->default(1); $table->integer('session_duration')->default(0); $table->json('custom_data')->nullable(); $table->timestamps(); $table->index(['app_id', 'timestamp']); $table->index(['app_id', 'url']); $table->index(['app_id', 'session_hash']); $table->index(['app_id', 'is_new_session']); });
3. Models
// app/Models/App.php <?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; class App extends Model { protected $fillable = ['name', 'api_key', 'domain']; public function analyticsData() { return $this->hasMany(AnalyticsData::class); } protected static function boot() { parent::boot(); static::creating(function ($app) { if (!$app->api_key) { $app->api_key = 'app-' . Str::random(20); } }); } } // app/Models/AnalyticsData.php <?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class AnalyticsData extends Model { protected $fillable = [ 'app_id', 'timestamp', 'url', 'referrer', 'anonymized_ip', 'browser', 'device', 'country', 'session_hash', 'is_new_session', 'pageview_count', 'session_duration', 'custom_data' ]; protected $casts = [ 'timestamp' => 'datetime', 'is_new_session' => 'boolean', 'pageview_count' => 'integer', 'session_duration' => 'integer', 'custom_data' => 'array', ]; public function app() { return $this->belongsTo(App::class); } }
4. Dashboard Controller
// app/Http/Controllers/DashboardController.php <?php namespace App\Http\Controllers; use App\Models\App; class DashboardController extends Controller { public function index() { $apps = App::withCount('analyticsData')->get(); return view('dashboard.index', compact('apps')); } public function app(App $app) { $stats = [ 'total_pageviews' => $app->analyticsData()->count(), 'unique_visitors' => $app->analyticsData()->where('is_new_session', true)->count(), 'today_pageviews' => $app->analyticsData()->whereDate('timestamp', today())->count(), 'today_visitors' => $app->analyticsData() ->whereDate('timestamp', today()) ->where('is_new_session', true) ->count(), 'top_pages' => $app->analyticsData() ->select('url') ->selectRaw('COUNT(*) as pageviews') ->selectRaw('COUNT(CASE WHEN is_new_session = 1 THEN 1 END) as unique_visitors') ->groupBy('url') ->orderByDesc('pageviews') ->limit(10) ->get(), 'countries' => $app->analyticsData() ->select('country') ->selectRaw('COUNT(CASE WHEN is_new_session = 1 THEN 1 END) as unique_visitors') ->selectRaw('COUNT(*) as pageviews') ->whereNotNull('country') ->groupBy('country') ->orderByDesc('unique_visitors') ->limit(10) ->get(), 'avg_session_duration' => $app->analyticsData() ->where('session_duration', '>', 0) ->avg('session_duration'), ]; return view('dashboard.app', compact('app', 'stats')); } }
π GDPR Compliance
β Why no consent is required:
- No cookies - Package sets no cookies
- Immediate anonymization - IP becomes
192.168.1.0
- No user tracking - No persistent user identification
- Data minimization - Only necessary data
- Legitimate interest - Art. 6 Para. 1 lit. f GDPR
- Session hashing - Anonymous daily session hashes, not traceable
π‘οΈ Anonymization:
Original | Anonymized |
---|---|
192.168.1.123 |
192.168.1.0 |
Mozilla/5.0 Chrome/91.0... |
Chrome |
2025-08-04 14:23:45 |
2025-08-04 14:00:00 |
Munich, Bavaria |
DE |
Session ID | abc123def456... (daily hash) |
βοΈ Advanced Usage
Manual Tracking
use Wappomic\Analytics\Facades\Analytics; // Track custom event Analytics::track([ 'url' => '/newsletter-signup', 'custom_data' => ['campaign' => 'summer-sale'] ]); // Check status if (Analytics::isEnabled() && Analytics::isConfigured()) { // Analytics is running } // Test API connection if (Analytics::testConnection()) { echo "β API reachable"; } else { echo "β API problem - check config"; }
Manual Middleware Control
// routes/web.php // Automatic tracking for all routes (default) Route::get('/', HomeController::class); // Disable tracking for specific routes Route::get('/admin', AdminController::class)->withoutMiddleware('analytics.tracking'); // Track only specific routes Route::group(['middleware' => 'analytics.tracking'], function () { Route::get('/shop', ShopController::class); Route::get('/products', ProductController::class); });
π Performance & Monitoring
- Middleware overhead: < 2ms
- Asynchronous: Via Laravel Queues (recommended)
- Request deduplication: Prevents duplicate tracking with Redis cache
- Smart filtering: Load balancer health checks and internal requests
- Retry logic: 2x retry with exponential backoff (5s, 15s)
- Timeout: 10 seconds
- Production logging: Clean logs with optional verbose debugging
Verbose Logging
For debugging, enable detailed logging:
ANALYTICS_VERBOSE_LOGGING=1 APP_DEBUG=true # Required - verbose logging only works when Laravel debug is enabled
Debug logs include:
- Request middleware triggers with headers
- API request/response details
- Queue job processing steps
- Duplicate request detection
- Performance metrics
Production logs (always enabled):
- API failures with HTTP details
- Configuration errors
- Job completion status
π§ Troubleshooting
No data received?
- Check config:
php artisan tinker >>> Analytics::validateConfig() >>> Analytics::testConnection()
- Enable verbose logging:
ANALYTICS_VERBOSE_LOGGING=1 APP_DEBUG=true
Then check logs:
tail -f storage/logs/laravel.log | grep -i analytics
- Queue running?:
php artisan queue:work
# Or temporarily disable:
ANALYTICS_QUEUE_ENABLED=false
Duplicate tracking (3x same data)?
Solution: Enable verbose logging to see duplicate detection:
ANALYTICS_VERBOSE_LOGGING=1 APP_DEBUG=true
Look for Analytics duplicate request detected
in logs. If still occurring:
- Check middleware registration - ensure only registered once
- Verify Redis cache - deduplication requires working cache
- Load balancer setup - health checks might bypass deduplication
Queue problems with Redis?
If you're using Redis and the queue isn't working:
- Check Redis connection:
php artisan tinker >>> Redis::ping() # Should return "PONG"
- Configure Redis queue explicitly:
ANALYTICS_QUEUE_CONNECTION=redis ANALYTICS_QUEUE_NAME=analytics REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379
- Check failed jobs:
php artisan queue:failed
php artisan queue:retry all # Retry failed jobs
- Monitor queue in real-time:
php artisan queue:work --verbose --tries=3 --timeout=30
- Check logs:
tail -f storage/logs/laravel.log
API Debugging
// Your analytics API should return: HTTP/1.1 200 OK Content-Type: application/json {"status": "success"} // On errors: HTTP/1.1 400 Bad Request {"error": "Invalid data", "details": [...]}
π Next Steps
- Generate API keys for your apps
- Implement dashboard with examples above
- Add charts (Chart.js, ApexCharts)
- Real-time updates with WebSockets
- Export functions (PDF, Excel)
π License
MIT License - See LICENSE for details.
Happy Analytics! π
Feel free to create an issue on GitHub if you have questions.
π©πͺ Deutsche Version
Die vollstΓ€ndige deutsche Dokumentation finden Sie in der README.de.md Datei.
For the complete German documentation, please see README.de.md.