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 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 as batch.dead_letter.retention_days keeps it: 30 days by default, without limit at 0, 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 driver batch.driver names: database, redis, file or array, 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;

MatomoGdpr::forget(Segment::where('userId', '==', '[email protected]')->definition());

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.

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

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:

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