Self-hosted Laravel analytics: real-time dashboards, advanced filters, and localized exports — multiple sites, no cookies, no mandatory consent banners.
Everything you need in one place — without handing visitor data to third parties or weighing down the sites you track.
document.title, query
string, language, timezone, search terms, and bot detection.From the multi-site overview down to the snippet you paste on your pages. Every screenshot comes from the built-in demo data.
One command. PHP 8.4+, Composer, and Node 20+. SQLite by default — MySQL and PostgreSQL work too.
git clone https://github.com/andreapollastri/indiestats.git cd indiestats composer run setup
The setup script installs dependencies, runs the Vite build, creates .env,
generates the app key, and runs migrations.
composer install npm install npm run build cp .env.example .env php artisan key:generate touch database/database.sqlite php artisan migrate php artisan db:seed
From zero to your first metrics in three steps.
Clone the repo, run composer run setup and php artisan db:seed to create
the first admin (admin@users.test / password — change it), then serve the app
with php artisan serve or Laravel Herd.
Add a site with a name and allowed domains. Copy the async script and noscript fallback
before the closing body tag — the base URL is your APP_URL.
Multi-site overview with live visitor counts, then nine per-site tabs — all narrowed by the same filters and exportable to localized Excel.
Explore every screen without installing the snippet anywhere: one command builds a realistic year of traffic.
php artisan migrate:fresh --seed --seeder=DemoDataSeeder
Sign in as admin@users.test / password. The demo creates five fictional sites — a
developer blog, open-source docs, a coffee shop, a SaaS landing page and a design studio — with about 230,000
pageviews, growth and seasonality, returning visitors, UTM campaigns, a Hacker News spike, purchase funnels,
crawlers, a small team with roles and live activity. It takes about 10 seconds on SQLite and only uses
.example domains.
Real-time views show the last few minutes. Refresh them right before a demo:
php artisan db:seed --class=DemoLiveTrafficSeeder
APP_ENV=production, and
re-running the demo replaces only the demo sites.
A lightweight async snippet plus a 1×1 pixel for visitors without JavaScript.
<script async src="https://your-indiestats-url/i/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.js"></script> <noscript><img src="https://your-indiestats-url/collect/pixel.gif?k=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&p=/" width="1" height="1" /></noscript>
The script tracks pageviews, time on page, outbound clicks, UTM parameters, on-site search queries, page title, query string, language, timezone, and a per-tab session ID.
indiestats.track('signup');
indiestats.track('purchase', {
plan: 'pro',
price: '29.99',
currency: 'USD'
});
Up to 20 key–value properties per event. Tie events to goals in the dashboard to watch conversions.
Origin /
Referer). Always set allowed domains in production.
A multi-site overview, then dedicated tabs for every angle of traffic.
UI is localized in IT, EN, DE, FR, and ES — including DataTables, country names, and export sheets.
One filter bar. Every tab, chart, realtime count, DataTables AJAX call, and Excel export respects it.
| Filter | Description |
|---|---|
| Source | Referrer / engine |
| Search term | On-site search query |
| Page / Page title | Path and document.title |
| Query string | Full landing-page query |
| UTM (5) | source, medium, campaign, term, content |
| Event | Custom event name |
| Device / Browser / OS | From User-Agent |
| Language / Timezone | From the tracker script |
| Visitor / type | Persistent ID · human vs bot |
| Country / ASN | GeoIP and network |
Deploy without surprises. Set APP_URL to HTTPS, build assets, run the scheduler and
a queue worker.
npm run build php artisan config:cache php artisan route:cache php artisan view:cache
| Variable | Notes |
|---|---|
APP_URL |
Required. Public HTTPS URL used by the tracker snippet. |
APP_ENV / APP_DEBUG |
production / false |
QUEUE_CONNECTION |
Needed for Excel exports (default database) |
ANALYTICS_RETENTION_DAYS |
Raw data pruning window (default 375) |
GEOIP_* |
Optional MaxMind / ASN database paths and license key |
CF-Connecting-IP is used for GeoIP and ASN. For
other reverse proxies, configure Laravel trusted proxies.
One cron entry drives backups, pruning, weekly GeoIP updates, monthly ASN downloads, and self-updates.
* * * * * cd /path-to-indiestats && php artisan schedule:run >> /dev/null 2>&1
| Command | Schedule | Purpose |
|---|---|---|
backup:run |
Daily 00:00 | Back up the database, .env and storage/app to BACKUP_DISK |
backup:clean |
Daily 01:00 | Remove old backups |
analytics:prune |
Daily 02:00 | Remove raw pageviews, events, and outbound clicks past retention |
geoip:update |
Mondays 04:15 | Download GeoLite2-Country when a MaxMind key is set |
dbip-asn:update |
3rd of month 04:30 | Download DB-IP ASN Lite |
app:update pending |
Every minute | Start the update an admin asked for on the Updates page |
app:update apply --auto |
Daily at APP_UPDATE_TIME (04:00) |
Install the newest build of the channel, while automatic updates are on |
Excel exports run as background jobs. Locally, run php artisan queue:listen next to the server.
In production, keep one running:
php artisan queue:work --sleep=3 --tries=3
Use Supervisor (or equivalent) so the worker restarts automatically.
A production installation updates itself from GitHub, from the scheduler.
Admins manage it on the Updates page: the installed release, the channel, the nightly
update (APP_UPDATE_TIME, 04:00) and an update on demand, which starts within a minute. The
cron entry is required.
1.2.3; v1.2.3 and
1.2 count too); Beta follows the head of main.storage/app/updates/backups, the newest 5 are kept),
installs the build, runs composer install --no-dev (assets are committed, so no Node),
migrates, refreshes the caches, restarts the queue workers and checks that IndieStats boots. If a step
fails, the previous code and the database come back; the log is in
storage/app/updates/update.log.releases/<build> next to the running one, sharing its .env and
storage, and current is switched to it.php artisan app:update status # the last check and the last run (JSON) php artisan app:update check # what the channel would install php artisan app:update apply [--force] # update now php artisan app:update channel stable # or beta php artisan app:update auto off # or on
The scheduler's user needs git, composer and the database client. Settings:
APP_UPDATES (on in production), APP_UPDATE_REPOSITORY,
APP_UPDATE_CHANNEL, APP_UPDATE_AUTO, APP_UPDATE_TIME,
APP_UPDATE_BACKUP, APP_UPDATE_ASSETS, and APP_UPDATE_PHP,
APP_UPDATE_COMPOSER, APP_UPDATE_NPM, APP_UPDATE_GIT for tools missing
from its PATH.
Installations from before 1.0.0 need one last manual update (git pull or
cipi deploy).
Run IndieStats on any Ubuntu VPS with cipi.sh — PHP-FPM, MariaDB, Nginx, Let's Encrypt, zero-downtime releases, and queue workers.
Updates work there too: IndieStats builds the new release next to the running one, switches
current to it and restarts Cipi's workers. A later cipi deploy keeps working as
before.