Database tables
The package ships two tables, and they are not written in the same situations.
matomo_tracking_buffer backs batch mode with the
database driver, and only that: an application on queue or sync mode never touches it.
matomo_dead_letters is written from both delivery modes — the flusher parks an exhausted
batch there in batch mode, and the queued job does the same in queue mode. So an
application on the shipped default does use one of these tables, and opting out of the
migrations without also switching the dead-letter store off leaves the delivery path with
nowhere to park. See opting out.
The migrations are registered automatically, so php artisan migrate creates them. To keep
them out of your schema entirely, see opting out below.
matomo_tracking_buffer
The cross-request hit buffer. One row per buffered hit.
| Column | Type | Holds |
|---|---|---|
id | auto-increment | Primary key. |
payload | json | One built Tracking-API hit payload. |
claimed_by | string, nullable | The claim token of the process currently sending this row. |
claimed_at | datetime, nullable | When it was claimed. |
created_at | datetime, nullable | When the hit was buffered. |
⚠️ These are datetime columns rather than timestamp, and on MySQL that is the difference
between a working claim and a silent one. MySQL converts a TIMESTAMP from the session time
zone on the way in and back on the way out, so a worker on a different zone reads every open
claim as an hour off — a batch still being sent gets reclaimed and Matomo counts it twice, or a
dead worker's batch is never released. TIMESTAMP also ends in 2038: a failed_at beyond it is
rejected outright. datetime has neither property and runs to 9999. PostgreSQL maps both to
timestamp without time zone, so nothing changes there, and SQLite stores text either way.
Version 0.27.0 converts existing installations; nothing is required of you.
Two indexes. (claimed_at, id) is the shape a flush pass queries by, since it asks for the
oldest unclaimed rows; claimed_by is what the same pass reads the claimed rows back with,
and what ack() and release() delete and clear by.
These rows are personal data. A buffered hit is a whole Tracking-API payload — the visitor
IP as cip, the user agent, the URL and the referrer — sitting in your database until it is
delivered. MatomoGdpr::forget() erases them here as well as at Matomo, for the two segment
forms it can evaluate without guessing; see GDPR requests.
claimed_by and claimed_at are what make delivery safe: a flush claims a batch before
sending, so a second flush cannot pick up the same rows, and a row is deleted only once
Matomo has confirmed a 200. A claim whose process died is reclaimed after
batch.stale_after_minutes.
The table name is configurable with batch.table.
matomo_dead_letters
Batches that exhausted delivery — a payload Matomo permanently rejected, or transient
failures past batch.max_attempts. One row per dead-lettered batch.
| Column | Type | Holds |
|---|---|---|
id | auto-increment | Primary key. |
payloads | longText | The batch's hits, one JSON object per line. |
hits | unsignedInteger | How many hits the batch carried. |
attempts | unsignedInteger | Delivery attempts made before it was given up on. |
error | text, nullable | The last error. |
failed_at | datetime, nullable | When it was dead-lettered. |
Indexed on failed_at, and the history is worth a sentence because it went both ways. The
column shipped with an index, no query used it — the recent list orders by id, the replay
walks by id — so it was dropped, paying an insert cost for nothing. The retention window
filters on exactly this column, so the index came back and is now earned rather than
precautionary.
Nothing here is lost data — it is data waiting for a decision, for as long as the retention
window below keeps it.
matomo:replay --list reads this table, and matomo:replay pushes the hits
back into the buffer. attempts is the useful diagnostic: a low number means a permanent
rejection, a number at batch.max_attempts means transient failures persisted.
The table name is configurable with batch.dead_letter.table, and the whole store can be
switched off with batch.dead_letter.enabled. In batch mode that reintroduces the
head-of-line blocking the store exists to prevent; in queue mode there is no buffer to
block, and an exhausted batch lands in failed_jobs instead. See
reliability.
Growth and retention
Neither table grows without bound in normal operation. The buffer is drained continuously, and rows are deleted on acknowledgment.
The dead-letter table used to be the one to watch: it only ever grew until somebody replayed
or pruned it. It is now bounded by batch.dead_letter.retention_days, which defaults to 30
days and is applied by a daily scheduled prune. Set it to 0 to go back to keeping
everything.
Bounding it does not make the queue something to ignore. A batch arriving here is analytics
data that failed to deliver, and 30 days is a window for noticing and acting — not a promise
that it will still be replayable at the end of it. Listen for the HitsDeadLettered
event so you find out when it starts filling, rather than discovering it later.
matomo:replay --prune empties it now; matomo:replay --prune-older-than=N deletes only
entries older than N days.
Verified engines
The buffer's at-least-once claim behavior, the acknowledge and reclaim cycle, and UTF-8 JSON round-tripping are covered by tests that run against real PostgreSQL and MySQL 8.4 in addition to SQLite — so the delivery guarantee is proven on the engines a production deployment actually uses, not only on an in-memory database.
Opting out
If you never use batch mode with the database driver, keep the tables out of your schema:
use MatomoAnalytics\MatomoAnalyticsServiceProvider;
MatomoAnalyticsServiceProvider::ignoreMigrations();
Call it from a service provider's register() method. To manage the migrations yourself
instead, publish them and edit the copies.
⚠️ Publishing is only half of it — call ignoreMigrations() as well, or migrate breaks
permanently. vendor:publish rewrites the 0001_01_01_00000N_ prefix to the publish date,
and the migrator keys on the FILE NAME — so the copies are five different migrations, not
replacements. Measured on both engines: ten migrations found instead of five, the bundled ones
run, the published create then dies with a duplicate-table error, and every later migrate
dies the same way.
php artisan vendor:publish --tag=matomo-analytics-migrations
php artisan migrate
See installation.
PostgreSQL: two things to set that this package cannot set for you
No statement timeout means push() waits as long as the lock does. The buffer write runs
from the framework's terminating() callback — after the response, but in the same worker, and
under Octane in a long-lived one. With an ACCESS EXCLUSIVE lock on the buffer table, which a
migration, an ALTER TABLE or a VACUUM FULL takes, that write blocks for exactly as long as
the lock is held. Measured on PostgreSQL 18: 22.9 seconds against a 22.9-second lock, with no
upper bound; the same write with lock_timeout = 2s failed after 2.00 seconds with SQLSTATE
55P03, which the package's own error handling absorbs.
Laravel's pgsql connector has no option for these, so they are set on the role or the
database:
ALTER ROLE app_user SET lock_timeout = '5s';
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';
matomo:test reports what the connection actually resolved, so you can check rather than
assume.
The package opens no transaction, and cannot protect you from yours. Every buffer operation
is a standalone statement — 160 of them per drain, with no BEGIN — which is deliberate: a
transaction spanning the send would hold row locks across an HTTP call to a third party. The
consequence is worth stating: if you call matomo:flush or Matomo::flush() from inside your
own transaction, all three claim statements join it and those row locks last until you commit.
Measured at 7.72 seconds against roughly 19 milliseconds outside one, and with no timeout set,
without a bound.
Why the (claimed_at, id) index is still here
PostgreSQL never chooses it. Measured over a full drain of a one-million-row backlog: the
primary key took 205,534 scans, claimed_by 1,448, and (claimed_at, id) zero — the OR
in the claim predicate rules out an ordered index scan, and ORDER BY id LIMIT is answered
through the primary key instead. Forcing it makes size() slower, not faster (98.8 ms against
76.2 ms).
It is kept because that measurement is PostgreSQL's alone, and MySQL's optimizer is a different question that has not been measured. Dropping an index on one engine's evidence is how the next regression gets written; the cost is 26–60 MB per million rows.
Rolling a migration back deletes the data in these tables
Both down() methods drop their table, which is what a migration's down() is for — and it
means a rollback destroys undelivered hits in the buffer and the whole dead-letter
archive, which is the one thing this schema describes as "data waiting for a decision".
The sharp edge is a fresh installation: this package's migrations then sit in the SAME batch as
your own, so a migrate:rollback meant to undo your last migration takes the archive with it.
Run matomo:replay --list first if there is anything parked, or take a dump.
migrate:fresh and migrate:refresh do the same thing without the batch subtlety.
The Laravel 12 half of the migration proof runs on SQLite
A compatibility harness boots a real Laravel 12 application and runs these migrations against an in-memory SQLite database. That proves they RUN on Laravel 12; it proves nothing about the engines this package supports, and the three defects fixed in 0.27.0 — the identifier-length ceiling, the session-timezone shift, the 2038 boundary — are each invisible to SQLite by construction. The suites that exercise real PostgreSQL and MySQL cover Laravel 13 only, because the test runner cannot be installed alongside Laravel 12.