Skip to content

Modernize tracker for 4.0.0: PHP 8.1, strict types, static analysis, full test coverage - #152

Merged
sgiehl merged 28 commits into
masterfrom
modernize-php-8.1-strict-types-static-analysis
Jul 27, 2026
Merged

Modernize tracker for 4.0.0: PHP 8.1, strict types, static analysis, full test coverage#152
sgiehl merged 28 commits into
masterfrom
modernize-php-8.1-strict-types-static-analysis

Conversation

@sgiehl

@sgiehl sgiehl commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

Modernizes the Matomo PHP Tracker for a 4.0.0 major release:

  • Raises the PHP requirement to 8.1 and enables declare(strict_types=1)
  • Adds native parameter/return/property types to every method and property, aligned with how Matomo core coerces the corresponding tracking parameters (core/Tracker/Request.php)
  • Migrates the false sentinels to null, removes #[AllowDynamicProperties], and fixes several latent type bugs
  • Refines a few parameter contracts to match core semantics: nullable goal/ecommerce revenue amounts (an explicit 0 is now distinct from "unset"), setUrlReferrer(?string) and setCustomTrackingParameter(string|array), plus an @internal setDebugTrackingParameter() test helper
  • Adds PHPStan (max level) and the Matomo coding standard (PHPCS), enforced via a new GitHub Actions quality workflow
  • Migrates the test suite to PHPUnit 10.5, updates the CI matrix to PHP 8.1–8.5

Latent type bugs fixed along the way:

  • cookies with purely numeric names (integer keys in $_COOKIE) caused a TypeError under strict types, breaking tracker construction entirely
  • a failed stream-fallback request without an HTTP response threw a TypeError in parseIncomingCookies() (on PHP < 8.4) instead of returning false
  • latitude/longitude of exactly 0.0 were silently dropped
  • setUserId(null) now de-assigns the User ID, as the docs always promised

Tests

The unit test suite grew from 17 to 136 tests (418 assertions), covering everything testable without network I/O:

  • every do* / getUrlTrack* method incl. all exception paths
  • regression tests for each documented 4.0.0 behavior change
  • custom variables/dimensions, ecommerce, client hints, attribution, visitor/user id precedence, bulk tracking, first-party cookies, curl/stream request preparation
  • a new TestableMatomoTracker double captures requests/cookies instead of doing I/O
  • the test suite itself is analysed by PHPStan at max level

Related issues

Breaking changes

See the 4.0.0 section in CHANGELOG.md for the complete list.

Checks

Local: PHPUnit (136 tests) ✅ · PHPStan max ✅ · PHPCS (Matomo standard) ✅ — on PHP 8.3.
CI validates PHP 8.1–8.5 across Linux/Windows/macOS plus PHPStan and PHPCS.

sgiehl added 11 commits July 24, 2026 16:41
- Drop PHP 7.2-8.0 support; require ^8.1.
- Bump PHPUnit to ^10.5 (PHP 8.1 floor rules out PHPUnit 11).
- Add PHPStan ^2, PHP_CodeSniffer ^3.10, the Matomo coding standard
  (matomo-org/matomo-coding-standards via VCS repo) and the phpcs
  composer-installer plugin.
- Pin config.platform.php to 8.1.0 and allow the installer plugin.
- Add composer scripts: test, phpstan, phpcs, phpcbf.
- phpunit.yml: drop PHP 7.2-8.0 from the matrix, bump to
  actions/checkout@v4, and install the json + curl extensions.
- Add quality.yml running PHPStan and PHP_CodeSniffer on PHP 8.1
  for every pull request and push to master.
Add declare(strict_types=1) to both source files and give every
method proper parameter and return type hints aligned with how
Matomo core coerces each tracking parameter. Declare all class
properties explicitly with concrete/nullable types and array-shape
PHPDocs (the class keeps #[AllowDynamicProperties] for consumer BC).

Optional 'unset' parameters and their backing properties/getters
move from the legacy `= false` sentinel to nullable (`?T = null`);
fluent setters now declare `: self`, and the do* tracking methods
declare `: string|bool`.

Fixes latent type issues that become fatal TypeErrors under strict
types (previously masked by weak-mode coercion), including:
- sendRequest() now returns string|bool (bulk mode really returns
  true, not the coerced "1"); cascaded to all do* methods.
- forceDotAsSeparatorForDecimalPoint() no longer returns null/false
  from a : string method.
- setCustomTrackingParameter() casts the dimension id to int.
- doTrackSiteSearch()/getUrlTrackSiteSearch() use ?int and only emit
  &search_count when a count is provided (no longer always sends 0).
- getUrlTrackCrash()/getUrlTrackEcommerceOrder() cast int/id values
  before urlencode(); getRequest() casts lat/long before urlencode().
- getUrlTrackEvent() guards the nullable name/value correctly.
- clientHints is reset to [] (not false) in bulk mode.
- getCustomVariablesFromCookie() validates decoded cookie data.

BREAKING CHANGE: passing mismatched scalar types now throws
TypeError instead of being silently coerced, and unset sentinels are
null rather than false. This targets a new major release.
Analyse both source files at level max with the PHP 8.1 platform.
No baseline: the codebase is fully clean at max level.
Add phpcs.xml.dist referencing the Matomo coding standard with a
400-char line limit and scoped exceptions: the two source files
intentionally mix a class with global helper functions/includes and
stay in the global namespace for backwards compatibility.

Apply the resulting fixes (blank line after the open tag, control
structure / operator spacing, trailing newline) and clean up the
test file: reorder the file header, add constant visibility, and
rename the test methods to camelCase.
- phpunit.xml.dist: run --migrate-configuration (drop the removed
  verbose attribute, refresh the schema to 10.5, add cacheDirectory);
  keep backupGlobals.
- Make the data-provider methods static as required by PHPUnit 10.
- Ignore the new .phpunit.cache directory.
- README: state the PHP 8.1+ requirement and add a Development
  section covering the composer test/phpstan/phpcs scripts.
- CHANGELOG: add the 4.0.0 entry describing the breaking changes
  (PHP floor, strict types, false->null sentinels, do* return type,
  search_count / stream-fallback behaviour) and the new tooling.
- .gitattributes: export-ignore phpstan.neon.dist and phpcs.xml.dist
  so dist archives stay lean.
All properties are now declared explicitly, so the tracker no longer
creates dynamic properties and the attribute is unnecessary. Dropping
it makes undeclared dynamic property writes raise the standard PHP 8.2
deprecation, in line with the strict, fully-typed rewrite.
The method documentation always promised that the User ID can be
unset again, but the string type hint made that impossible. Accept
null to reset it, matching the null sentinel used everywhere else.
- Cast cookie names to string before strpos(): cookies with purely
  numeric names are exposed as integer keys in $_COOKIE and caused a
  TypeError under strict_types, breaking tracker construction.
- Do not read $http_response_header when the stream fallback request
  failed without an HTTP response; previously this caused a TypeError
  in parseIncomingCookies() instead of returning false.
- Send latitude/longitude values of 0.0 instead of silently dropping
  coordinates on the equator or prime meridian.
- Guard the POST URL split against URLs without a query string and
  replace an always-true elseif condition with else.
- Remove dead branches in setEcommerceView() that guarded against
  values the string type hints now make impossible.
- Restore the dropped $category docblock in doTrackSiteSearch(), fix
  the setClientHints() fullVersionList docblock shape, remove leftover
  array() syntax and strval() calls, and document all behavior changes
  in the 4.0.0 changelog.
- Add tests for every tracking URL parameter, all do*/getUrlTrack*
  methods, custom variables/dimensions, ecommerce, client hints,
  attribution info, visitor/user id handling, bulk tracking, cookie
  handling and request preparation, including regression tests for
  the behavior changes of the 4.0.0 release (search_count omission,
  bulk true return, null sentinels, numeric cookie names).
- Introduce a TestableMatomoTracker capturing requests and cookies
  instead of performing network or header I/O.
- Analyse the test suite with PHPStan at max level.
- Add a phpunit <source> section and a CI coverage job (pcov).
Comment thread MatomoTracker.php Outdated
Comment thread MatomoTracker.php Outdated
Comment thread MatomoTracker.php Outdated
sgiehl added 4 commits July 25, 2026 19:01
…param helper

Findings from auditing tracker usage across Matomo core and plugins:

- setUrlReferrer()/setUrlReferer() accept ?string again so callers can
  unset the referrer with null (used by several Matomo fixtures).
- setCustomTrackingParameter() accepts string|array again; array values
  are serialized via http_build_query exactly as before 4.0.0, restoring
  multi-value parameters used by FormAnalytics and HeatmapSessionRecording.
- Add setDebugTrackingParameter(string, string) (@internal): appends a raw,
  unvalidated parameter that overrides any built-in of the same name, so
  Matomo integration tests can verify server-side handling of malformed
  values without bypassing the tracker. Cleared per request.

Adds unit tests and CHANGELOG entries for all three.
- Pin shivammathur/setup-php to a commit hash (2.37.2) instead of the
  floating @v2 tag, mitigating supply-chain risk (Aikido high finding).
- Set persist-credentials: false on actions/checkout steps so the
  GITHUB_TOKEN is not left in the git config for later steps (Aikido).
Drop the pcov-based coverage job for now; the standard PHPUnit matrix,
PHPStan and PHPCS jobs remain.
Core treats an absent 'revenue' as 'use the goal's configured revenue',
and defaults the optional ecommerce amounts to false (not 0), so a real
zero is meaningful and must be distinguishable from 'not provided'.

- doTrackGoal()/getUrlTrackGoal() (+ Matomo_/Piwik_ helpers) take
  ?float $revenue = null: null omits revenue, 0.0 now sends revenue=0.
- doTrackEcommerceOrder()/getUrlTrackEcommerceOrder()/getUrlTrackEcommerce()
  take ?float $subTotal/$tax/$shipping/$discount = null, sent only when
  provided; the required grand total is now always sent (0.0 -> revenue=0).

Previously an explicit 0/0.0 was silently dropped by the !empty() guards.
Adds tests and a CHANGELOG entry.
@sgiehl
sgiehl force-pushed the modernize-php-8.1-strict-types-static-analysis branch from 4607868 to faec8a4 Compare July 25, 2026 17:02
sgiehl added 5 commits July 25, 2026 20:17
- URL-encode the cip (override IP) tracking parameter before adding it to
  the request, matching every other value and preventing tracking-parameter
  injection when an application sets cip from untrusted input. Fixes #151.
- Remove the curl_close() call guarded by PHP_VERSION_ID < 80000: it is dead
  code now that PHP 8.1 is the minimum, and curl_close() is a no-op (and
  deprecated in PHP 8.5) on all supported versions. Fixes #149.
Append &ca=1 (custom action) to the event and content-tracking URLs,
matching the JS tracker. This prevents Matomo from falling back to
recording these requests as page views when the handling plugin is
disabled. Fixes #80.
Change the default requestTimeout from 600s to 5s and connect timeout
from 300s to 2s, so a slow or unreachable Matomo can no longer block the
calling page for up to 10 minutes. Callers can still raise them via
setRequestTimeout()/setRequestConnectTimeout(). Fixes #88.
Allow callers to pass additional cURL options (e.g. CURLOPT_IPRESOLVE,
CURLOPT_HTTP_VERSION) that are applied after the built-in options, so
they can tune the request without the library committing to opinionated
defaults. Fixes #92.
Previously the cURL transport threw a RuntimeException on DNS/connection
failures while the stream transport silently returned false. Both now
throw by default (preserving cURL behavior and making the two transports
consistent). Call setExceptionsEnabled(false) to make failed requests
return false instead, so tracking never breaks the calling application.
Refs #105.
@sgiehl
sgiehl force-pushed the modernize-php-8.1-strict-types-static-analysis branch from 2c54e59 to 5e85858 Compare July 25, 2026 19:11
sgiehl added 5 commits July 25, 2026 22:27
…exceptions

- URL-encode the remaining raw query values: _refts (from attribution
  data), customData (data) and pageCharset (cs), so a value can no longer
  inject extra tracking parameters.
- Validate the visitor ID read from the first-party cookie as a
  16-character hexadecimal string in loadVisitorIdCookie(), matching
  setVisitorId().
- Request-failure exceptions now include only the target host, never the
  full URL (which can carry token_auth/PII in its query string).
- Build the outgoing Cookie header as url-encoded pairs joined with '; '
  (was http_build_query, which joined with '&'), and parse every incoming
  Set-Cookie header by its first '=' so multiple cookies accumulate
  (parse_str previously overwrote the whole set per header and applied
  query-string bracket semantics). getIncomingTrackerCookie() now returns
  string|false.
- setAttributionInfo() no longer echoes the supplied JSON in its exception
  message and marks the parameter #[\SensitiveParameter].
…ports, bulk)

- Mark sendRequest() $url and $data as #[\SensitiveParameter] so they are
  redacted from exception stack traces (token_auth/PII).
- Force POST when token_auth is placed in the request body, so the stream
  transport no longer sends it as a GET body that Matomo ignores.
- Stream transport sets ignore_errors so HTTP 4xx/5xx return the response
  body (parity with cURL) instead of a failure.
- Bulk tracking uses a >=30s timeout and retains the queued actions when a
  batch fails to send (previously discarded), allowing retry.
- Docs: clarify setUserId(null) does not unlink the current server-side
  visit; drop the stale 'coverage reported in CI' CHANGELOG claim.

Adds tests for all of the above.
- getUrlTrackAction()/doTrackAction() now URL-encode the action type (not
  just the value), so a crafted or plugin-specific action parameter cannot
  inject an additional query-string parameter.
- Extract the visitor-id hex character set into a single HEX_CHARACTERS
  constant used by both setVisitorId() and loadVisitorIdCookie().
- Make cURL detection overridable via hasCurlSupport() so the stream
  transport can be exercised in tests; add coverage for the stream
  failure (host-only exception message) and fail-safe paths.
- README: add an 'Error handling and timeouts' section covering the
  default exceptions, the short 5s/2s timeouts, setExceptionsEnabled(),
  setRequestTimeout()/setRequestConnectTimeout() and bulk retention.
- CHANGELOG: correct the multi-value BC note to reference pre-3.4.0 and
  mention that the download/link action type is now URL-encoded.
- phpcs.xml.dist: explain that the 400-char line limit matches Matomo
  core's own coding standard rather than being an arbitrary value.
Comment thread MatomoTracker.php
@sgiehl
sgiehl marked this pull request as ready for review July 27, 2026 11:40
sgiehl added 3 commits July 27, 2026 15:27
sendRequest() appends token_auth into the URL/body and passes them to
prepareCurlOptions()/prepareStreamOptions(). #[\SensitiveParameter] only
redacts the frame where the parameter is declared, so mark those params
too: should either builder (or something it calls) ever throw, the token
and PII carried in $url/$data are redacted from that frame as well.
The blanket 'mismatched scalar now throws a TypeError' claim was
misleading: strict/weak mode is set by the *caller's* file, so a normal
(non-strict) consumer still gets weak-mode coercion. Legacy calls that
passed false to mean 'not known' (doTrackEvent value, addEcommerceItem
price, setLatitude/setLongitude, ...) now silently send 0/0.0/'' instead
of omitting the parameter. Add an explicit upgrade note and correct the
TypeError wording so the transition is spelled out.
…ders

setCurlOptions() applied caller options via array_replace(), so a caller
adding a single header wholesale-replaced the tracker's own header list.
For POST/bulk requests that dropped the Content-Type, making Matomo unable
to parse the body — a silent failure. Merge (append) custom HTTP headers
onto the built-in ones instead, and document the special-casing.

Also extend the sensitive-parameter test to assert redaction on the
transport option builders (prepareCurlOptions/prepareStreamOptions), not
just sendRequest, since the URL/body are forwarded to them.
@sgiehl
sgiehl merged commit bde2d19 into master Jul 27, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants