Skip to main content

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
MatomoGdpr::export('[email protected]'); // export the subject's data
MatomoGdpr::forget('[email protected]'); // erase; returns deletion counts

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.

$matches = MatomoGdpr::findDataSubjects('[email protected]');

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:

php artisan matomo:forget "[email protected]" # confirm, then erase
php artisan matomo:forget "[email protected]" --force # no prompt
php artisan matomo:forget "[email protected]" --export # export instead
php artisan matomo:forget "[email protected]" --site=all

--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.