benmacha / mousetracker
Self-hosted mouse, click and scroll tracker for Symfony: heatmaps, scroll maps and session replay. React dashboard usable from Twig, plain JS, React and Vue.
Package info
github.com/BenMacha/mouseTracker
Language:JavaScript
Type:symfony-bundle
pkg:composer/benmacha/mousetracker
Requires
- php: >=7.2.5
- doctrine/doctrine-bundle: ^2.0
- doctrine/orm: ^2.7 || ^3.0
- doctrine/persistence: ^1.3 || ^2.0 || ^3.0 || ^4.0
- symfony/asset: ^5.4 || ^6.4 || ^7.0
- symfony/config: ^5.4 || ^6.4 || ^7.0
- symfony/dependency-injection: ^5.4 || ^6.4 || ^7.0
- symfony/framework-bundle: ^5.4 || ^6.4 || ^7.0
- symfony/http-foundation: ^5.4 || ^6.4 || ^7.0
- symfony/http-kernel: ^5.4 || ^6.4 || ^7.0
- symfony/routing: ^5.4 || ^6.4 || ^7.0
- symfony/twig-bundle: ^5.4 || ^6.4 || ^7.0
- symfony/yaml: ^5.4 || ^6.4 || ^7.0
- twig/twig: ^2.12 || ^3.0
Requires (Dev)
- phpunit/phpunit: ^8.5 || ^9.6 || ^10.5 || ^11.0
- symfony/var-exporter: ^5.4 || ^6.4 || ^7.0
Suggests
- symfony/security-bundle: Required to protect the dashboard with mouse_tracker.access_role.
Provides
None
Conflicts
None
Replaces
None
README
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.
Table of contents
- 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
Why MouseTracker
| 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 |
Features
- 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.
Requirements
| Package | Version |
|---|---|
| PHP | >= 7.2.5 |
| Symfony | 5.4, 6.4, 7.x |
| Doctrine ORM | 2.7+, 3.x |
Installation
composer require benmacha/mousetracker
Register 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: /tracker
The 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 public
The 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'
Record your site (zero code)
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.
Dashboard
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 is read-only
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).
Configuration
# 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'
Theming
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.
HTTP API
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} |
Security & GDPR
- 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.
FAQ
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.
Development
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)
Upgrading from 2.x
- 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).
License
MIT © Ali Ben Macha — contributions welcome, see CONTRIBUTING.md.