Open source Hotjar / Mouseflow / Microsoft Clarity alternative for Symfony 5.4, 6.4 and 7.x:
mouse movement & click heatmaps, scroll maps and session recording / replay,
stored in your database. GDPR friendly, no third-party service, plugs into any site with one <script> tag.
- Why MouseTracker
- Features
- Requirements
- Installation
- Record your site (zero code)
- Dashboard — Twig, plain JavaScript, React, Vue
- Replay is read-only
- Configuration
- Theming
- HTTP API
- Security & GDPR
- FAQ
- Upgrading from 2.x
| MouseTracker | SaaS (Hotjar, Mouseflow, Clarity…) | |
|---|---|---|
| Data location | your own database (Doctrine) | third-party servers |
| Cost | free, MIT | per session / per month |
| GDPR / privacy | no data leaves your infrastructure | data processor agreement needed |
| Integration | Symfony bundle + one script tag | external script |
| Dashboard | inside your admin (React, Vue, Twig, Web Component) | external website |
- Session recording & replay — cursor, clicks (numbered), scroll, hover states, viewport resizes, typed values; timeline with seek, 1× to 8× speed, skip inactivity, event log, every page of the visit.
- Heatmaps — mouse movement heatmap, click heatmap, scroll map (75 / 50 / 25 % of visitors), per device width (desktop / tablet / mobile).
- Zero-code recorder —
<script src="https://your-api/tracker/recorder.js">, 12 KB, no dependency, works with server-rendered pages and single page apps (React, Vue, Angular:history.pushStatenavigations are followed). - Pluggable dashboard — React component, Vue 3 component,
<mouse-tracker-dashboard>Web Component (Shadow DOM) or Twig function; themable with CSS variables (follows shadcn/ui, Tailwind, Bootstrap themes), light & dark, English & French. - Read-only replay — the replayed page can read but never write (no POST, no GraphQL mutation, no form submit).
- Privacy controls — sampling, opt-out, never records passwords / card fields, keyboard recording can be disabled, IP exclusion, page exclusion, kill switch.
- Symfony 5.4 → 7.x, PHP 7.2.5 → 8.4, Doctrine ORM 2 & 3, MySQL / MariaDB / PostgreSQL / SQLite / SQL Server.
| Package | Version |
|---|---|
| PHP | >= 7.2.5 |
| Symfony | 5.4, 6.4, 7.x |
| Doctrine ORM | 2.7+, 3.x |
composer require benmacha/mousetrackerRegister the bundle (if Symfony Flex did not):
// config/bundles.php
benmacha\mousetracker\TrackerBundle::class => ['all' => true],Import the routes:
# config/routes/mouse_tracker.yaml
mouse_tracker:
resource: '@TrackerBundle/Resources/config/routes.yaml'
prefix: /trackerThe ingest routes (public) and the dashboard routes (to protect) can also be imported separately:
@TrackerBundle/Resources/config/routes/ingest.yaml and @TrackerBundle/Resources/config/routes/dashboard.yaml.
Create the tables (tracker__client, tracker__page, tracker__data) and publish the assets:
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
php bin/console assets:install publicThe Doctrine mapping is XML and is detected automatically with auto_mapping: true. Otherwise:
doctrine:
orm:
mappings:
MouseTracker:
type: xml
is_bundle: false
dir: '%kernel.project_dir%/vendor/benmacha/mousetracker/src/Resources/config/doctrine'
prefix: 'benmacha\mousetracker\Entity'Any website or single page app — one tag, nothing to install in the front-end project:
<!-- before your application scripts, without async/defer -->
<script src="https://api.example.com/tracker/recorder.js" data-exclude="/login,/admin"></script>The recorder finds its endpoint from its own URL. Attributes: data-exclude (paths never recorded),
data-spa="false" (do not follow pushState navigations), data-endpoint, data-debug.
Symfony / Twig sites, without touching templates:
mouse_tracker:
auto_inject: true # adds the tag before </body> of every HTML page
auto_inject_exclude: ['^/admin', '^/_']or explicitly in a template: {{ mouse_tracker_script() }}.
From code (npm), if you prefer:
import { startRecorder } from '@benmacha/mouse-tracker/recorder';
const recorder = startRecorder({ endpoint: 'https://api.example.com/tracker', excludePaths: ['/login'] });
// recorder.optOut() · optIn() · stop() · newPage()When the site and the API are on different domains, allow the site origin (allowed_origins)
or let your CORS bundle handle /tracker/*.
What is recorded: mouse positions (every delay ms), clicks, scroll positions, viewport
resizes and — unless record_keyboard: false — input values on blur. Never passwords, hidden,
file or card fields; never elements with class mt-ignore / noRecord or attribute data-mt-ignore.
Open /tracker/back/, or embed the dashboard in your own admin:
Twig
{{ mouse_tracker_dashboard({ height: '800px', theme: 'auto', locale: 'fr' }) }}Plain JavaScript / any framework — Web Component served by the bundle, no npm package:
<script src="/bundles/tracker/build/dashboard.js"></script>
<mouse-tracker-dashboard api="/tracker" theme="auto" locale="fr" style="height: 85vh"></mouse-tracker-dashboard>
<script>
// non-string options (authentication headers…) go through the `options` property
document.querySelector('mouse-tracker-dashboard').options = {
headers: () => ({ Authorization: 'Bearer ' + localStorage.getItem('token') }),
};
</script>React
import { MouseTrackerDashboard } from '@benmacha/mouse-tracker';
<MouseTrackerDashboard apiUrl="https://api.example.com/tracker" headers={() => ({ Authorization: `Bearer ${token}` })} theme="auto" />Vue 3
<script setup>
import { MouseTrackerDashboard } from '@benmacha/mouse-tracker/vue';
</script>
<template>
<MouseTrackerDashboard api-url="/tracker" theme="auto" height="85vh" />
</template>The npm package is the bundle repository itself (npm install ../vendor/benmacha/mousetracker).
More examples in examples/.
| Option | Description |
|---|---|
apiUrl |
Base URL of the dashboard routes |
siteUrl / resolvePageUrl(page) |
Where recorded pages are loaded (default: recorded host) |
headers |
Extra headers, object or function (authentication) |
credentials, fetch |
fetch options |
theme |
light, dark, auto |
locale |
en, fr |
title, height, className, view, injectStyles, onViewChange |
Replay and heatmaps load the real page in an <iframe name="mousetracker-replay">, logged in
with the viewer's own session. In that frame the recorder does not record; it installs a guard that
blocks every request that could change data: POST / PUT / PATCH / DELETE, GraphQL
mutations, sendBeacon and form submissions (GraphQL queries and GET requests still work so the
page renders). Clicks are not re-triggered unless the viewer ticks Replay clicks in the page, and
even then nothing can be saved. Load the recorder synchronously before your application so the
guard is active before the first request.
The tracked site must accept being framed by the dashboard (X-Frame-Options / frame-ancestors).
# config/packages/mouse_tracker.yaml
mouse_tracker:
enabled: true # false: script served, nothing recorded (kill switch)
auto_inject: false # add the recorder to every HTML page
auto_inject_exclude: ['^/_(profiler|wdt|error)', '^/admin']
exclude_paths: [] # pages never recorded (prefixes), sent to the recorder
record_click: true
record_move: true
record_keyboard: true
percentage_recorded: 100 # sample visitors
disable_mobile: false
ignore_ips: [] # checked server side
delay: 200 # ms between two mouse positions
max_moves: 800 # per page view
session_timeout: 40 # seconds of inactivity before a new session
ignore_query_params: [utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid]
entity_manager: ~ # entity manager holding the tracker__* tables
allowed_origins: [] # CORS for recorders on other origins ("*" = any)
max_payload: 2097152 # bytes per request
access_role: ~ # e.g. ROLE_ADMIN for the dashboard and its API
site_url: ~ # base URL of the tracked site in the replay frames
title: 'Mouse Tracker'Every colour is a CSS custom property; they cross the Shadow DOM:
mouse-tracker-dashboard, .my-admin {
--mt-accent: #e11d48;
/* follow a shadcn/ui (Tailwind) theme, light and dark */
--mt-bg: hsl(var(--background));
--mt-surface: hsl(var(--card));
--mt-surface-2: hsl(var(--muted));
--mt-border: hsl(var(--border));
--mt-text: hsl(var(--foreground));
--mt-muted: hsl(var(--muted-foreground));
}Tokens: --mt-accent, --mt-accent-foreground, --mt-bg, --mt-surface, --mt-surface-2, --mt-border,
--mt-text, --mt-muted, --mt-stage, --mt-danger, --mt-success, --mt-radius, --mt-font, --mt-font-mono.
Ingest (public):
| Method | Path | |
|---|---|---|
| GET | /recorder.js |
the recorder |
| GET | /config |
{ settings, ignored } |
| POST | /createClient |
token, clientID?, url, domain, resolution, source, versionMobile → { clientID, clientPageID } |
| POST | /addData |
clientPageID, token, movements?, clicks?, partial?, cachedRecords? |
Dashboard (protected):
| Method | Path |
|---|---|
| GET | /back/ (page), /api/settings, /api/stats?domain= |
| GET | /api/sessions?domain=&q=&from=&to=&offset=&limit= |
| GET | /api/pages?domain=&q=, /api/recordings/{pageViewId} |
| GET | /api/heatmap?url=&domain=&type=movements|clicks&minWidth=&maxWidth=, /api/scrollmap?url=… |
| DELETE | /api/sessions/{id}, /api/pageviews/{id} |
- Ingest routes are public by design;
addDataonly accepts data for a page view whose session token matches. - Protect the dashboard:
access_roleand/oraccess_controlon^/tracker/(back|api). With a stateless API (JWT), put those routes behind your API firewall and pass the token with theheadersoption. - Ask for consent where required, disable
record_keyboardwhen forms may contain personal data, useexclude_pathsfor sensitive pages andenabled: falseto stop collecting instantly.
Is it a free alternative to Hotjar, Mouseflow, Smartlook or Microsoft Clarity? Yes for heatmaps, scroll maps and session replay, self-hosted in your Symfony application (no surveys or funnels).
Does it work with React, Vue or Angular single page apps?
Yes. The recorder follows history.pushState / popstate navigations and the dashboard ships as React, Vue and Web Components.
Do I need Node.js or npm?
No. The built files are committed; composer require is enough. npm is only needed to modify the front-end.
Will replaying a session submit forms or delete data? No. See Replay is read-only.
Which databases are supported? Every database supported by Doctrine ORM (MySQL, MariaDB, PostgreSQL, SQLite, SQL Server…).
How much data is stored?
Mouse positions are sampled (delay, max_moves); recordings are JSON chunks attached to page views.
Delete sessions from the dashboard or the API.
composer update && vendor/bin/phpunit # PHP tests (kernel + SQLite)
npm install && npm test # Vitest
npm run typecheck && npm run build # rebuild src/Resources/public/build (committed)- Same tables and columns (an index is added on
tracker__page(domain, date)). - Doctrine mapping moved from attributes to XML (
src/Resources/config/doctrine), PHP sources tosrc/. {{ mouse_tracker_service.build()|raw }}still works; prefer{{ mouse_tracker_script() }}orauto_inject.- The recorder sends the session token with every request.
- The jQuery back-office and
/back/getPages,/back/getClients,/back/getDataare replaced by the React dashboard and/api/*. - IP filtering is done server side (no third-party IP service).
MIT © Ali Ben Macha — contributions welcome, see CONTRIBUTING.md.