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 tables. The batch buffer and the dead-letter
table hold whole hits —
cip,ua,url,urlref,uid— and a dead letter can sit there for up to thirty days. Erasing only at Matomo would let you report a request fulfilled while the same person's address was still in your database.
The return value keeps them apart, because they are different systems:
[
'log_visit' => 2, // Matomo's own counts
'log_link_visit_action' => 7,
'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
]
⚠️ 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.
The local half runs even when Matomo matched nothing, because the buffer holds hits that have not reached Matomo yet.
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.
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. 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.