Skip to main content

Upgrading

The package follows Semantic Versioning, and every release documents its changes in the changelog in Keep a Changelog format. Read the changelog entries between your version and the target one; the notes below are what applies to every upgrade.

composer update pushery/matomo-analytics-for-laravel

Pre-1.0

The package is in its 0.x series. Under SemVer that means the minor number carries what a major number will carry after 1.0, so read the changelog for a minor bump rather than treating it as automatically safe. Most minors here have added features — but not all of them: 0.16.0 changed three shipped defaults on purpose, 0.17.0 changes the page-view numbers a tracked site reports, 0.19.0 can stop tracking outright for an installation with an older published config file, and 0.24.0 needs a php artisan migrate. Each says so at the top of its changelog entry. See the sections below.

There is no 0.18.0 release. That version was prepared and never published, so it has no tag and was never on Packagist. Its changes — which move reported figures in three directions — ship in 0.19.0, and its changelog entry is kept for that reason. If you are coming from 0.17.0 or earlier, read it too.

Upgrading to 0.28.0 — two of your numbers move, and both were wrong before

Nothing to do. Everything 0.28.0 added is opt-in and inert until you ask for it: require_tls ships off, site_id_resolver ships unset, and the Horizon tags a queued batch now carries are read by Horizon or ignored by every other queue driver.

This section exists for the two patch releases after it. Neither needs an action either, and both correct a number you may have been reading — so the change shows up in your reports on the day you deploy, which is worth knowing before someone reads it as a traffic event. If you annotate deployments, release annotations put a marker on exactly that day.

Your Core Web Vitals will change, and the old ones were inflated (0.28.1)

Only if your application uses Livewire's navigationwire:navigate — and renders @matomoWebVitals. Everywhere else the snippet ran once per page load all along and nothing moves.

Livewire re-executes every script in the body it swaps in. Both snippets register their listeners on document, which survives that swap, so each hop left the previous registration in place and added another beside it: measured in a consuming application at 11 listeners added and 0 removed per navigation. For Web Vitals that reached the data. With N sets of observers watching a metric, N beacons were sent for it — so a published score scaled with how deeply a session had browsed, and deep sessions weighed more than shallow ones.

Expect LCP, CLS and INP to move, and expect the new numbers to be the correct ones. The direction is a fall in beacon volume; the direction of each score depends on which pages your deep sessions were reading. The tracker snippet was inflating nothing measurable, but it was inserting matomo.js and replaying its _paq configuration on every hop, and that stops too.

Your page-view count will rise where cache validators are in play (0.28.2)

Only if you are coming from 0.27.0 or later and your application answers with ETag or Last-Modified headers.

middleware.only_successful was implemented as "2xx", and a 304 Not Modified is not 2xx — so it was dropped. But a 304 is a delivered page: the reader has it, and the server only declined to resend the bytes. On a site with cache validators that is the second and every later view of a page, which means return visits stopped being counted at all.

The hole opened in 0.27.0, when tracking moved into terminate(). Before that the middleware ran inside the request and an application could order its ETag middleware behind the tracker, so the tracker still saw the untouched 200; terminate() runs after the whole stack, where the status is always final and always the 304.

Expect page views to rise on a site like that, back to what they should have been. Visits and visitors move with them, because the return visits that were being dropped are real ones. A redirect is still not counted, deliberately: it delivers no page, and the page it lands on is tracked on its own request.

Upgrading to 0.27.0 — one default moved, and two middleware now run later

One change can alter what you measure, and one changes resource use. Neither needs action.

batch.size defaults to 200 instead of 50

It is the round-trip knob: draining 2000 hits against a Matomo answering in 20ms took 1021ms at 50 and 276ms at 200 — the same hits over the same reused connection. It is also the memory knob, which the old config comment did not say: a claimed batch is held at roughly 2.3 KB per hit, so a flushing process holds about 460 KB where it held 115 KB.

Set MATOMO_BATCH_SIZE=50 if you would rather keep the old value.

The page-view and site-search middleware track from terminate()

They ran behind $next() in handle(), so the visitor waited through the gate, the payload build and the buffer write — and in sync mode through the whole request to Matomo. Nothing about what is tracked changes, and the server generation time behind middleware.performance is still measured before the response leaves.

One thing to know if you test these middleware directly: a test that calls handle() and then asserts will now see nothing tracked. Call terminate($request, $response) afterwards, the way the kernel does.

Upgrading to 0.26.0 — nothing to do, unless you want the failure report back

No action required. One new setting, and it exists to undo a trade-off 0.24.0 made.

MATOMO_SCHEDULE_BACKGROUND=false gets the exit code reaching your scheduler again

Both scheduled commands run in the background, unchanged from 0.24.0, so schedule:run never waits on Matomo. What that costs is the failure report: Laravel raises a scheduled command's non-zero exit only when the event is not in the background, so a background one dispatches no ScheduledTaskFailed and never reaches your exception handler. A nightly prune that fails is invisible to Sentry, Flare or Nightwatch.

Until now that could not be declined. Set MATOMO_SCHEDULE_BACKGROUND=false when the report matters more than the wait. The TrackingFailed and HitsDeadLettered events remain the other channel and are unaffected either way.

Upgrading to 0.25.0 — the release that lets Laravel 12 install 0.24.0 at all

Read this one if 0.24.0 refused to install on a Laravel 12 application.

The Symfony floor was raised past what Laravel 12 asks for

0.24.0 declared symfony/http-foundation and symfony/http-kernel at ^7.4.0 || ^8.0.0 — Laravel 13's own constraint — on a package that also promises Laravel 12, whose framework asks for ^7.2.0. Every Laravel 12 application holding Symfony 7.2 or 7.3 was locked out of a release that runs on it perfectly well, while the README went on saying "Laravel 12 or 13".

The floors are back at the base version of each supported major. If your resolver refused 0.24.0 with a Symfony conflict, upgrade straight to 0.25.0 or later; nothing else changed.

Upgrading to 0.24.0 — run a migration, and check three assumptions

This is the largest upgrade in the 0.x series so far. One required step and four changes that can alter behavior you may be relying on.

Run php artisan migrate

A new index on matomo_tracking_buffer.claimed_by ships as its own migration, so existing installations get it too. Three of the five buffer operations filter on that column and none had an index to use — invisible while the buffer is small, and expensive exactly when a backlog builds, which is the situation the buffer exists for.

Matomo::track() outside a request lifecycle now needs Matomo::flush()

batch mode collects during the request and writes once at the end, the way queue mode already did. Inside a request nothing changes: the service provider flushes on terminate. In a command, a job, or a test that tracks without a request, the hit now sits in memory until something flushes it — call Matomo::flush() when you need the buffer written immediately.

TrackingQueued fires once per request in batch mode, not once per hit

It carries the request's whole payload list, matching queue mode. A listener written as a hit counter now counts requests. Read count($event->payloads) instead of incrementing by one.

A scheduled command's exit code no longer reaches the scheduler

Both scheduled commands run in the background, so schedule:run no longer waits on an HTTP call to Matomo — and a background event does not throw on a non-zero exit. If you were relying on the scheduler surfacing a failed flush or prune, listen for TrackingFailed and HitsDeadLettered instead. 0.26.0 adds MATOMO_SCHEDULE_BACKGROUND=false if you would rather have the exit code back.

DeadLetterStore::take() returns a generator

It materialized every row and its decoded payload tree at once — roughly 168 MB for a two-thousand row backlog before the first hit moved. foreach over the result is unaffected. Code that indexes into it needs iterator_to_array().

Upgrading to 0.23.0 — only if you implement ReportClient yourself

No action required unless you have your own ReportClient implementation.

The contract now declares all 27 methods the facade advertises

It carried 5 while the facade advertised 27, so the documented way to reach the read side — injecting ReportClient — could not call visitsSummary() under static analysis. It ran fine; the promise simply was not written where a type checker could read it.

Implementing it is still a five-method job. Every helper is one call to get() with a fixed Matomo method name, and the ResolvesCommonReports trait supplies all 22 from it. An existing implementation adds one use statement. Both shipped implementations needed no change.

Upgrading to 0.22.0 — an opt-out that was being ignored now works

One change needs your attention, and only if you switched the dead-letter store off.

Turning the dead-letter store off now really turns it off

batch.dead_letter.enabled has always been documented as the way to stop parking failed batches in matomo_dead_letters. The batch flusher has always honored it. The queued delivery job did not — it wrote to the store regardless, so an installation that had switched the store off kept accumulating rows in it.

This matters more than it first sounds, because mode defaults to queue. An installation that set the flag and never changed the dispatch mode was in exactly the case that ignored it.

What changes for you. If you set enabled => false, an exhausted batch no longer lands in matomo_dead_letters. What happens instead depends on the mode, and the two genuinely differ:

ModeWhere an exhausted batch goes
batchit stays in the buffer, as it always has
queuethe job fails the ordinary way, so the batch lands in failed_jobs

Both are visible and neither loses hits — but if you monitor the dead-letter table and not failed_jobs, point your monitoring at both. php artisan queue:failed lists them.

If you never touched enabled, nothing changes: the store is on by default and keeps working exactly as in 0.21.0, including the 30-day window that release introduced.

Nothing else needs action

The remaining changes need no decision from you:

  • illuminate/translation is now declared as a dependency. On a full laravel/framework install it was already present; on the slim component-only install this package supports, rendering the privacy-policy partial used to fail. composer update pulls it in.
  • The daily dead-letter prune no longer errors when the table does not exist — relevant if you suppress the package migrations or run with the store switched off.
  • matomo:test now points out settings your running application cannot see because its configuration is cached. It is advisory and never fails the command.

Upgrading to 0.21.0 — dead letters are no longer kept forever

One change needs a decision from you, and it is about data you may have been keeping.

Dead letters are deleted after 30 days

Batches that gave up on delivery are parked in matomo_dead_letters. Until this release nothing ever removed them — matomo:replay deletes an entry when it re-queues it, and --prune empties the queue, but both need someone to run them. An installation where nothing goes wrong therefore accumulated the table indefinitely, one full batch of hits per row.

A daily scheduled prune now deletes entries older than 30 days.

If you have been treating that table as a permanent diagnostic archive, set the window off before you upgrade:

// config/matomo-analytics.php
'batch' => [
'dead_letter' => [
'retention_days' => 0, // 0 keeps everything, as before
],
],

or in .env:

MATOMO_DEAD_LETTER_RETENTION_DAYS=0

0 means "no limit", the same convention --max-runs, --max-time and --memory already use in this package.

Why the default is on rather than off. Beyond the table simply growing, an old dead letter is misleading to replay. A hit's timestamp is stamped when the payload is built, so replaying a month-old batch sends a month-old timestamp — and Matomo refuses a backdated hit older than about a day unless the request carries token_auth. Without one it records the hit at today's date instead, quietly moving old visits into your current report. An entry nobody has looked at in a month is past the point where replaying it helps.

Entries whose failure time was never recorded are never deleted, whatever the window.

A migration adds an index

The prune filters on failed_at, so that column is indexed again. It shipped with an index originally, lost it in 0.19.0 because no query read it, and now earns it back. Run php artisan migrate as usual.

Nothing else needs action

The remaining changes are internal: the package no longer calls any Laravel Foundation global helper in shipped code (it never should have — the dependency list promises component-only), and the AI crawler list gained two tokens.

Upgrading to 0.20.0 — where undelivered hits end up, and for how long they are retried

Nothing to do on your side, and no configuration change is needed. Two things move, and both are visible only when Matomo is unreachable.

Undelivered hits go to the dead-letter table, not to failed_jobs

A queued batch that exhausts its delivery attempts is now recorded in the package's dead-letter table, the same place batch mode has always put an undeliverable batch. It used to be left to Laravel's failed_jobs.

That heading describes the default. If you have switched the dead-letter store off with batch.dead_letter.enabled = false, failed_jobs is once again where an exhausted queued batch lands — see reliability for what the opt-out means in each delivery mode, because the two are not the same sentence.

Nothing is lost either way — what changes is where you look for it:

php artisan matomo:replay --list # what is parked, with attempts and last error
php artisan matomo:replay # put it back in the buffer

If you built a habit around queue:retry for these, or an alert on failed_jobs growth, that alert will go quiet. That is the change, not a fault.

The retry window is queue.tries again, and it is much shorter

queue.tries (default 5) now bounds the retry loop. It did not before — Laravel skips its max-attempts check whenever a job defines retryUntil(), and this job always does, so the real budget was queue.retry_until_minutes: a full day of retrying, roughly ninety-six attempts per batch against an unreachable endpoint.

In practice a batch is now given up on after about twenty minutes instead of after a day. For analytics that is usually the better trade — a hit that has been failing for twenty minutes is rarely failing for a reason another twenty hours will fix, and every retry of a hit that Matomo may already have counted risks counting it twice.

If you were relying on the day-long window, raise queue.tries. The batch is not discarded when the budget runs out; it waits in the dead-letter table for matomo:replay.

Timeouts stop appearing in your error tracker

A transport timeout no longer reaches your application's exception handler. It used to, on every attempt, regardless of resilience.reporting.report_after_attempts, throttle_minutes or even channel => 'silent' — those settings sit on the package's own reporting path, and a rethrown exception went around it through the queue worker.

Alerting still happens, through the reporter, where those three settings apply. If you would rather have the old, louder behavior, set resilience.never_throw to false.

Upgrading to 0.19.0 — check your published config file, or tracking may stop

This one has a case where tracking stops, and it is worth the two minutes.

It reaches you only if both of these are true, and the second one is what makes it rare:

  1. You have a published config/matomo-analytics.php that is missing enabled or anonymize_ip. Both keys have shipped at the top level of every published version, so this means you deleted one — trimmed the file to the settings you tune. It cannot happen just by publishing under an older release.
  2. You run php artisan config:cache. Without a cached config, Laravel's merge fills a missing top-level key back in from the shipped file, so the fallback below is never reached. Caching freezes the file as it stands, and then it is.

If neither applies, upgrade and read no further.

What was wrong

Every boolean in this package is read with a fallback for the case where the key is missing from your file. Two of those fallbacks disagreed with the value the shipped config declares:

KeyShipped file saysCode fell back to
anonymize_iptruefalse
enabledfalsetrue (in three of four places)

So a published file that predates one of these keys got the opposite of the documented default, silently. For anonymize_ip that meant hits carried full IP addresses despite a shipped default of true. For enabled it meant the package tracked, which is the exact opposite of the guarantee 0.16.0 introduced ("installing this package must never start tracking anyone").

Separately, a nested key missing from your file did not fall back at all. Laravel's config merge is flat: a top-level key present in your file replaces the shipped one whole, so every section you published froze at the shape it had that day. privacy.redact missing a key meant redaction silently doing nothing; spa.adapters missing meant no adapter.

What changes for you

If your file has both keys — the shipped file has always had them — nothing changes.

If your file is missing enabled and you cache your config, tracking now stops. That is the correct reading of absence for a dormancy guarantee: the package cannot tell "the key is missing" from "the operator wants it off", and those have to resolve the same way. Put the shipped line back:

'enabled' => env('MATOMO_ENABLED', false),

…and set MATOMO_ENABLED=true in the environments you actually want tracked. Note the false: that is the shipped default and it is deliberate. Writing true there would make the file the thing that enables tracking, so every environment that inherits it — a clone, a staging box, a colleague's laptop with no .env entry — starts tracking without anyone deciding to. The environment variable is where "yes, track here" belongs.

If your file is missing anonymize_ip and you cache your config, IP anonymization switches on. Your reported visitor counts may shift slightly, because a truncated address is a different input to visitor identification.

Nested keys you never set now arrive with their shipped defaults. If you deliberately trimmed a section down, check the configuration reference — a default you were implicitly relying on being absent is now present.

Lists are the exception, deliberately. If your file sets bots.deny or privacy.redact.query_params, that list is taken exactly as you wrote it, including when you emptied it. Merging into a list would hand back entries you deleted on purpose.

If you cache your config, rebuild the cachephp artisan config:cache. That single command re-runs the merge and picks up every nested default added since you published. Re-publishing the file is a separate thing and does nothing on its own while a stale cache is in place; see below.

Upgrading to 0.17.0 — your page-view count will fall

This one is worth two minutes even though the release adds nothing and removes no option. It only affects you if you set spa.enabled to true. That setting is off by default, so an application that has never enabled it is unaffected and can upgrade without reading on.

What was wrong

The livewire adapter listened for livewire:navigated. That event is not only fired when a soft navigation happens: Livewire's navigation plugin also fires it once, unconditionally, while it starts up — so it arrives on every full page load of a Livewire application, at the URL the tracker has just recorded a page view for, and whether or not the application uses wire:navigate anywhere at all.

Every full page load was therefore recorded twice. Enabling livewire and generic together had the same effect on a single soft navigation, for the same reason.

What changes for your reports

An adapter now records a virtual page view only when the URL actually changes.

Expect page views to drop for any site that had spa.enabled on. Visits, visitors and every unique-based metric are unaffected; what falls is the inflated count of page views, and the numbers after the upgrade are the correct ones. The drop is not gradual — it appears on the day you deploy, which is worth knowing before someone reads it as a traffic loss. If you annotate deployments, release annotations put a marker on exactly that day.

The one case that genuinely loses something

If you deliberately fired one of the adapter events at an unchanged URL to mark a screen the URL does not express — a tab, a step in a wizard, a modal route — that no longer records. Call the helper instead, which is never guarded and is meant for exactly this:

window.matomoTrackPageView();

One thing that improves without any action

The first soft navigation of a visit used to report an empty referrer, so Matomo read it as a direct entry and the flow reports began one step late. It now carries the URL the browser loaded normally, and the chain is complete from the first hop.

Upgrading to 0.16.0 — three defaults changed

0.16.0 changes what the package does when you have configured nothing. Each change makes the quiet option the safe one, and each can break an existing setup in exactly one direction: tracking stops. Nothing starts collecting more than it did before.

1. Tracking is off until you turn it on

enabled now defaults to false. Previously it defaulted to true and the package was inert only because host and site_id happened to be unset — which is a different promise: it made tracking begin as a side effect of configuration rather than as a decision.

MATOMO_ENABLED=true

If you were tracking and want to continue, set this. Without it matomo:test will tell you tracking is disabled and still probe the connection, so you can confirm the credentials before flipping the switch.

2. MATOMO_URL is no longer read

host used to fall back to env('MATOMO_URL') — a variable this package does not own. If your application had its own Matomo integration reading MATOMO_URL, configuring that one also configured this one. Only MATOMO_HOST is read now.

MATOMO_HOST=https://your-instance.matomo.cloud

3. Safer privacy defaults

anonymize_ip now defaults to true, and visitor.user_id to null instead of 'auth'. Both remain fully configurable — what changed is which one you have to ask for. If you relied on full IP addresses or on the Matomo User ID, set them explicitly:

'visitor' => ['user_id' => 'auth'],
'anonymize_ip' => false,

If you published the config file, none of this reaches you

This is the important caveat, and it cuts both ways. mergeConfigFrom lets your file win key by key, so a published config/matomo-analytics.php keeps whatever defaults it was generated with — including env('MATOMO_ENABLED', true) and the MATOMO_URL fallback.

  • Nothing breaks on upgrade. Your tracking continues exactly as before.

  • You also do not get the safer defaults. If the reason they changed matters to you — an application with its own consent gate, an EU deployment storing full IPs — then edit those four values in your published file, or diff it against the new one:

    php artisan vendor:publish --tag=matomo-analytics-config --force # overwrites; diff first

The configuration reference always shows the shipped defaults, which is what a fresh install gets.

If your application has its own consent layer, wire it into tracking.gate rather than gating the package from outside. It is consulted before every hit and overrides the built-in rules — see the tracking gate.

'tracking' => ['gate' => \App\Analytics\ConsentGate::class], // __invoke(Request, $hit): ?bool

You do not have to re-publish the config file

A release that adds a setting does not require you to re-publish config/matomo-analytics.php. The merge folds every key you did not mention in from the shipped file, at any nesting depth, so your application boots and behaves as it did.

This became true in 0.19.0. Before that release the sentence above was the intention rather than the behavior, in two ways: a nested key missing from your file fell back to nothing at all — Laravel's config merge replaces a top-level key whole — and two of the top-level fallbacks disagreed with the value the shipped file declares. If you are on an older version and have a published config file, read the 0.19.0 section above before you rely on this paragraph.

One thing it still does not cover, on any version: php artisan config:cache freezes the resolved config, and the framework skips merging while one is in place. The fix is to rebuild the cache, not to re-publishphp artisan config:cache re-runs the merge and bakes every key added since into the new cache. Re-publishing changes the file on disk and does nothing at all until the cache is rebuilt; reach for it when you want the new file's inline comments, or want to edit a new key by hand.

Behind a stale cache — one built before this release, never rebuilt — the package falls back to a value in code, and those fallbacks now agree with the shipped file for every single setting. List settings included: a key a stale cache does not carry is answered from the shipped file itself rather than resolving to an empty list. That closes the case where redaction or SPA tracking went inert without anything looking broken — privacy.redact.query_params ships two dozen entries, and a silent empty list there means the redaction step ran and removed nothing.

An explicitly empty list is still yours: it is a decision, not an absence, and nothing revives the shipped entries behind it. The answer to a cached config remains config:cache — this only stops a stale one from quietly disabling a privacy control.

That is why the configuration reference — not your published file — is the authoritative list of what exists. To adopt a new setting, add just that key to your file.

If you would rather take the whole new file and re-apply your changes:

php artisan vendor:publish --tag=matomo-analytics-config --force

--force overwrites. Diff it against your version first — that command discards your edits.

Three things to check after an update

1. New environment variables. The changelog names any new key. They always have a default, so nothing breaks by not setting one, but a feature you wanted may be off until you do. Two examples from past releases: MATOMO_JS_HOST to serve the tracker asset from a CDN, and MATOMO_ANNOTATE_RELEASES to enable deploy markers.

2. Migrations. The package registers its migrations automatically, so php artisan migrate picks up anything new. Two situations need action instead:

  • You published the migrations to manage them yourself — re-publish to get new ones.
  • You called MatomoAnalyticsServiceProvider::ignoreMigrations() — then new tables are yours to add, if you use the feature that needs them.

See installation.

3. Cached config and routes. Rebuild both after an update:

php artisan config:cache
php artisan route:cache

Route caching matters because the Web Vitals ingest route is registered by the package. It is registered unconditionally — precisely so that toggling the feature needs no route-cache rebuild — but a package update that changes routes does.

Framework and PHP support

Both supported majors are proved by a run rather than by a constraint: matomo:test-level smoke checks plus static analysis at level max execute against a real Laravel 12 application and a real Laravel 13 one on every change to the shipped tree. The Laravel 12 proof lives outside the test suite because Pest 5 and Laravel 12 cannot be installed together — laravel/framework 12 wants symfony/process ^7.2 and Pest 5 wants ^8.1 — which is a fact about those two packages and not about this one.

The current release requires PHP 8.4+ and Laravel 12 or 13. The supported range is stated in composer.json, so Composer refuses an upgrade your platform cannot run rather than installing something broken — a composer update that reports a conflict is telling you to raise PHP or Laravel first.

Published views and translations

If you published the privacy-policy partial, your copy is yours and is never touched by an update. Check the package's version after an upgrade if the release notes mention it — a published copy silently keeps describing the old behavior, which for a privacy policy is worth more attention than for a normal view.

The same applies to published translations.

Rolling back

Nothing in the package writes state that a downgrade cannot read: the buffer and dead-letter tables hold hit payloads, not versioned structures. If you need to pin, pin the version in composer.json and open an issue describing what broke — a 0.x release that needs a rollback is a bug worth knowing about.