GDPR data-subject requests
"Right to be forgotten" and access requests are handled through Matomo's
PrivacyManager API. You identify a person with a segment — a userId, a
visitIp, anything Matomo can segment on — and then export or erase every matching
visit.
use MatomoAnalytics\Facades\MatomoGdpr;
MatomoGdpr::findDataSubjects('visitIp==203.0.113.7'); // preview the matching visits
These operations need an admin-access
token. The read or tracking token is usually not enough. They are also never cached — a deletion that returned a cached answer would be worse than useless.
What forget() erases, and where
Two places, and the second one is easy to forget because it is yours:
- At Matomo, through
PrivacyManager.deleteDataSubjects, for every visit the segment matched. - In this application's own stores. The batch buffer and the dead-letter
table hold whole hits —
cip,ua,url,urlref,uid— and a dead letter stays there for as long asbatch.dead_letter.retention_dayskeeps it: 30 days by default, without limit at0, and only while the scheduler runs the daily prune (see how long dead letters are kept). Erasing only at Matomo would let you report a request fulfilled while the same person's address was still in your database. The buffer is searched through whichever driverbatch.drivernames:database,redis,fileorarray, including batches a flush has claimed but not yet confirmed.
The return value keeps them apart, because they are different systems:
[
'log_visit' => 2, // Matomo's own counts
'log_link_visit_action' => 7,
'erased_visits' => 2, // visits erased at Matomo, across every round
'erased_completely' => true, // false: run it again, see below
'local_buffer' => 1, // rows removed from matomo_tracking_buffer
'local_dead_letters' => 1, // hits removed from matomo_dead_letters
'local_segment_understood' => true, // whether the local half could act at all
'local_queue_searched' => true, // false in the queue mode, see below
]
Matomo answers a lookup with at most 401 visits. Its findDataSubjects stops there, so
forget() erases in rounds: it deletes what a lookup found and looks again while the lookup came
back full. erased_completely is false when the rounds ended with visits still matching, after
a hundred rounds or a lookup that brought back the visits it had just deleted; run the erasure
again. matomo:forget says so and exits with a failure. export() reads one lookup, so an export
for a person with more than 401 visits is partial. Export such a person in parts by date, each
under 401 visits, with the visitEndServerDate segment: [email protected];visitEndServerDate<2026-01-01,
then the same with >=2026-01-01. After a full lookup matomo:forget --export says so on the
error output, which keeps the JSON on standard output clean, and exits with a failure.
The local half understands exactly two segment forms: userId==<value> and
visitIp==<value>. A Matomo segment is an expression Matomo evaluates against its own schema,
and re-implementing that here against stored payloads would be a guess — a wrong guess deletes
somebody else's data. Anything else sets local_segment_understood to false, which is a
different answer from "nothing matched": the local stores were not searched, and if the
request needs them, run it again with one of the two forms.
Both halves read the value the way Matomo reads it: decoded up to three times, and each pass
turns a + into a space, so [email protected] names alice [email protected].
An address in visitIp is compared as an address, as Matomo compares it, so
visitIp==2001:db8::1 also finds a hit stored as 2001:DB8::1.
To name a value exactly, build the segment with the builder, which encodes it for those passes:
use MatomoAnalytics\Reporting\Segment;
The local half runs even when Matomo matched nothing, because the buffer holds hits that have not reached Matomo yet.
In the queue mode, the queue itself is out of reach. There a request's hits wait as a
queued job until a worker sends them, and a job that runs out of attempts keeps its hits in
failed_jobs. Neither can be searched from here, so local_queue_searched is false in that
mode. Let the workers drain the queue and run the erasure again, so that whatever they delivered
is erased at Matomo too, and prune the failed jobs that still hold the person's hits
(php artisan queue:prune-failed).
Preview before you erase
findDataSubjects() returns the matching visit rows, each carrying its site and visit
id. Run it first, always. A segment that matches more than you expect erases more than
you expect, and the operation is not reversible.
if ($matches === null) {
// The call failed — check MatomoGdpr::lastError()
} elseif ($matches === []) {
// Nothing matched. That is an answer: this person produced no visits.
}
The three-valued return is the same convention as the reporting client:
null means the call failed, [] means it succeeded and matched nothing.
lastError() holds Matomo's own text, which repeats the segment and with it the identifier of
the person the request is about. Show it to the operator who made the request rather than
logging it. The package's own report of the failure names the API and the method only.
Export and erase
export() returns the data subject's data, for handing to the person who asked.
forget() erases every matching visit and returns deletion counts keyed by storage
area, so you can record what was actually removed.
Both find the subject by segment first, so they operate on exactly what
findDataSubjects() showed you — provided nothing new arrived in between.
For the case where you already have the specific visits — from a previous
findDataSubjects() call you have vetted — deleteVisits() and exportVisits() take
the visit rows directly:
MatomoGdpr::deleteVisits([['idsite' => 1, 'idvisit' => 10]]);
From the command line
The command previews the match count and asks before deleting:
--site takes a site id, or all to search every site the token can see. Without it,
the configured site_id is searched — which is a real trap if the person used more
than one of your sites, so consider --site=all for genuine erasure requests.
--force exists for scripted erasure, and a script needs it: a run that cannot ask, under
-n, -q or --silent or from a job, deletes nothing without it and exits non-zero. If you
are wiring this into a self-service privacy flow, do the findDataSubjects() preview in
your own code and keep a record of it, because --force produces no preview to keep.
The audit trail
A DataSubjectForgotten event fires on every erasure, carrying the number of visits
erased and the per-area deletion counts:
use Illuminate\Support\Facades\Event;
use MatomoAnalytics\Events\DataSubjectForgotten;
Event::listen(function (DataSubjectForgotten $event): void {
// $event->visits, $event->deleted
});
Listen for it and you have a record of what was erased and when, which is exactly what a data-protection audit asks for. The event carries counts, not the erased data — it is an audit trail, not a backup.
Testing it
MatomoGdpr::fake() stubs the lookup and can simulate a failing mutation, so an
erasure flow can be tested without an admin token or a real Matomo. See
testing.