vskstudio / takt-core-php
Framework-agnostic PHP core for Takt analytics: browser snippet renderer + server-to-server event client.
Requires
- php: >=8.1
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-21 19:11:16 UTC
README
📚 Documentation: taktlytics.com/docs/wrappers/php
Framework-agnostic PHP core for Takt analytics: a browser snippet renderer and a server-to-server event client.
Install
composer require vskstudio/takt-core-php
SnippetRenderer
Render the tracking snippet server-side and echo it into your <head>.
use Vskstudio\Takt\SnippetRenderer; use Vskstudio\Takt\Options; $renderer = new SnippetRenderer(new Options( domain: 'example.com', outbound: true, files: true, )); echo $renderer->render();
Modes
The Mode enum controls how the bundle is delivered:
Mode::Inline(default): embeds the bundle inline in a<script>tag that self-boots. No extra request, and CSP-friendly: passnonce:inOptionsto emit anonceattribute.Mode::Cdn: emits a deferred loader pointing at the jsDelivr-hosted bundle.Mode::Asset: emits a deferred loader pointing at/takt/takt.auto.js, served from your own domain.Mode::Sdk: emits an ES-module<script type="module">import{init}…;init({…})</script>that boots the full SDK. The only mode able to expressscrubUrl(see below). The module is loaded from{scriptOrigin}/takt/takt.esm.jswhenscriptOriginis set, otherwise from jsDelivr. Serving it from your own domain is your responsibility: the vendored bundle only shipstakt.auto.js(forMode::Asset), not the ES module.
use Vskstudio\Takt\Mode; new Options(domain: 'example.com', mode: Mode::Cdn, nonce: $cspNonce);
The snippet honors domain, endpoint, scriptOrigin, excludeLocalhost and the advanced options below. SPA tracking is always on; Do-Not-Track respect is on by default and can only be turned off explicitly with respectDnt: false.
Where events are sent
By default the snippet ingests to the hosted Takt origin (https://taktlytics.com/api/event), so a bare setup works out of the box, including on static sites with no backend. To override:
endpoint: '/api/event': restore the same-origin first-party proxy (you forward/api/eventto Takt from your own server). Anti-adblock, but requires a backend.endpoint: 'https://collect.example.com/api/event': any custom absolute collector URL.scriptOrigin: 'https://t.example.com': let the tracker derive{scriptOrigin}/api/event(first-party, anti-adblock).endpointwins overscriptOrigin.
An endpoint with a scheme other than https is refused with an InvalidArgumentException, http://localhost included, because the server client sends the API key as a Bearer header on every call. A scheme-less endpoint is accepted only when it is a path starting with a single /, such as /api/event, for the same-origin proxy of the snippet; //host/…, \\host/… and host/… are refused. For local development, serve your collector over local https (for example with mkcert, whose certificate your machine trusts) and point endpoint at https://localhost:….
Autocapture is opt-in and bundled into the vendored takt.auto.js. Each toggle adds a token to a single data-auto attribute the tracker reads:
outbound: true: outbound link clicks (outbound)files: true: download clicks (downloads); narrow the matched extensions withfileExtensions: ['pdf', 'zip'], emitted asdata-downloads-extWithoutfileExtensions, the tracker matches its default list:pdf,zip,dmg,xlsx,csv,doc,docx,ppt,pptx,rar,7z,gz,mp3,mp4,wav,avi,mov,exe,apk,mkv,txt(21 extensions).tagged: true: elements tagged in HTML withdata-takt-event(tagged)notFound: true: 404 pageviews (404)
scriptOrigin sets a first-party origin to serve the tracker + derive the endpoint from ({origin}/api/event), for example a custom domain to dodge ad-blockers (endpoint wins over it). In Mode::Asset the loader src is also served from that origin ({origin}/takt/takt.auto.js).
Advanced options
Each is null by default ("unset": the tracker's own default applies); only a non-default value is rendered.
sampleRate: 0.5: keep ~50% of hits (data-sample-rate).trackQuery: true: keep the raw query string + hash in tracked URLs (data-track-query); off by default URLs are stripped.queryParams: ['utm_source', 'utm_medium']: allowlist of params to keep whentrackQueryis off (data-query-params).respectDnt: false: stop honoring the browser Do-Not-Track header (data-respect-dnt).enabled: false: kill-switch; the snippet still renders but the tracker boots disabled (data-enabled).scrubUrl: '(u) => u.split("#")[0]': a raw JS function to rewrite every URL before it is sent.exclude: ['/app', '/account']: path prefixes never tracked (segment-bounded:/appmatches/appand/app/…but not/application), checked at send time so it holds across SPA navigation.redactRoutes: ['/verify/{token}', '/reset/[code]']: sensitive routes, sent as their pattern instead of the real path. See Route redaction.routeTemplates: true: send every page as its route template. See Route redaction.routeTemplate: '/users/{id}': the route template of the page being rendered, used whenrouteTemplatesis on.
new Options(domain: 'example.com', sampleRate: 0.5, queryParams: ['utm_source']);
In Mode::Sdk, scrubUrl rewrites the page URL and referrer, and since @vskstudio/takt-core@0.9.0 also the url prop of autocaptured outbound link clicks (outbound: true) and file downloads (files: true). A token hidden in the path of an outbound or download link is therefore scrubbed like the page URL. The minimal snippet of the other modes only strips the query string and hash of those links.
scrubUrl cannot be expressed as a data-attribute, so it requires Mode::Sdk; constructing a SnippetRenderer with scrubUrl set in any other mode throws. It is injected verbatim into the page as JavaScript, and it is dev-controlled only. Never build it from user input.
exclude likewise lives only in the full SDK (the ≤ 1 kB minimal snippet omits it), so it too requires Mode::Sdk; setting it in inline/cdn/asset mode throws rather than silently dropping the exclusion, since a dropped privacy control would leak the very paths it is meant to hide.
new Options( domain: 'example.com', mode: Mode::Sdk, scrubUrl: '(u) => u.split("?")[0]', );
Route redaction
Query strings are stripped by default, but path segments are sent as they are: /verify/abc123 leaks the token. Two opt-in options replace real paths with route templates. Both live only in the full SDK, so they require Mode::Sdk; setting either in inline/cdn/asset mode throws.
new Options( domain: 'example.com', mode: Mode::Sdk, redactRoutes: ['/verify/{token}', '/reset/:code', '/invoices/[id].pdf'], );
redactRoutes lists the sensitive routes. A matching path is sent as the pattern, every other path keeps its real value. Patterns accept [param], [[optional]], [...rest], (group), :param, :param?, *, **, and the Laravel / Symfony syntax {param} and {param?}. The browser SDK reads the bracket syntax, so the renderer hands it {token} as [token] and {page?} as [[page]]: the browser sends /verify/[token] where the server client sends /verify/{token}. Write bracket patterns if both must report the same row. A constrained parameter such as {page<\d+>} keeps its brace form, because the constraint exists only there, so the browser needs an SDK that reads that form to enforce it.
Matching is applied to several spellings of the same path, so an encoded or unusual URL cannot slip past a pattern: the raw path, the path with %2F decoded as a separator, and the fully decoded path are all tried, the path being decoded up to five times. Each spelling has repeated slashes collapsed, its . and .. segments removed as in RFC 3986, and its matrix parameters (;...) ignored; matching is case-insensitive. A path still encoded after the five passes is sent as /[redacted], and so is a path that cannot be decoded at all (a malformed escape or invalid UTF-8 once decoded). With redactUndecodablePaths: false, such a path is decoded tolerantly and matched like any other: it is sent as the pattern it matches, and raw only when no pattern matches. When a pattern matches, the whole URL is replaced by the pattern, so the query string and fragment are dropped too and a token in ?token= cannot leak. If a pattern cannot be evaluated (the PCRE engine gives up on a very long segment), the path is sent as /[redacted], again without query or fragment, rather than in clear.
A constrained parameter {name<regex>} (for example {id<\d+>}) matches a segment only when its whole value matches regex, case-insensitively like the rest of the matching. A segment that breaks the constraint means the route does not apply, so the path is sent as it is, or as another pattern that matches it. The constraint cannot contain < or >: the first > closes it. A / inside <...> does not split the route into segments.
The regex follows a restricted grammar, the same in the PHP and JavaScript SDKs: alternation a|b only at the top level; literals (not < nor >), ., \d \D \w \W \s \S, a backslash before ASCII punctuation other than /, and classes [...] with ranges and no nesting. . matches any byte except /, and the grammar counts bytes, not characters: \d, \w and \s are ASCII only, so a two-byte character such as é is two bytes to .{2}. In a class, a - is literal only first or last, a range cannot end on -, on an escape or on </>, cannot span /, and a ] cannot open a class. Quantifiers *, +, ?, {n}, {n,} and {n,m} have bounds up to 100 ({0} is allowed, {,m} is refused; {n} is not counted as a variable quantifier), no lazy or possessive forms and no two in a row. A constraint holds at most two variable quantifiers ({n,n} counts), at most 100 characters, and no empty branch. Groups, lookarounds, backreferences, anchors (^, $) and / are refused. A constraint only ever sees one segment. A route is unusable when one of its constraints is refused, when its constraints hold more than two variable quantifiers in total, when one segment holds more than one constrained parameter or more than two variable quantifiers (each unconstrained parameter counts as one), when a stray { or } remains in a segment, when its ambiguity degree exceeds one, or when it holds more than one REST segment. A REST segment is **, *name, :name*, :name+ or [...name]; it counts zero in the ambiguity degree. The ambiguity degree is the sum, over the segments, of the variable quantifiers of each segment (unconstrained parameters included, and the ? of an optional parameter counts as one more) minus one, floored at zero per segment: /{a}{b}/{c} has degree one, /{a}{b}/{c}{d} has degree two, /{a?}/{b} has degree one and /{a?}/{b}{c} has degree two. An unusable route masks, as /[redacted], every path with the same shape: each parameter reads as any value, and the parameters of a segment read as one value. When the route holds a REST segment, its shape is the segments before the first REST segment, one ** standing for the span up to the last one, and the segments after it. Other paths are unaffected. A value longer than 256 bytes for a constrained parameter is masked as /[redacted] before the constraint is checked, even when it would not match. These limits reduce the ambiguous splits of a route, and a PCRE failure still masks the path, so write \w+ rather than (\w+)+.
new Options(domain: 'example.com', mode: Mode::Sdk, routeTemplates: true, routeTemplate: '/users/{id}');
routeTemplates: true sends every page as its route template. PHP renders each page on the server, so pass the template of the current route as routeTemplate: it is emitted as a constant routeTemplate: () => "/users/{id}" resolver, in canonical form ({id?} becomes {id}). Options::withRouteTemplate() and SnippetRenderer::withRouteTemplate() return a copy with a per-request template. The Laravel and Symfony bridges fill it for you.
Takt (server-to-server client)
Send events directly from your backend, attributed to the real visitor.
use Vskstudio\Takt\Options; use Vskstudio\Takt\Revenue; use Vskstudio\Takt\Takt; // $endpoint takes either form: a base origin ('/api/event' is appended) or the // full collect URL. Options::HOSTED_ORIGIN and Options::HOSTED_ENDPOINT both // target the hosted collector. $takt = new Takt(Options::HOSTED_ORIGIN, 'example.com', $apiKey); $takt ->withVisitor($request->ip(), $request->userAgent()) ->event('Signup', ['plan' => 'pro'], new Revenue('29.00', 'EUR'), 'https://example.com/signup'); // or a pageview $takt->withVisitor($ip, $userAgent)->pageview('https://example.com/welcome'); // sensitive routes are sent as their pattern $takt = new Takt(Options::HOSTED_ORIGIN, 'example.com', $apiKey, redactRoutes: ['/verify/{token}']); $takt->pageview('https://example.com/verify/abc123'); $takt->event('Verified', url: 'https://example.com/users/42', route: '/users/{id}');
- Requires an ingest-scoped API key bound to the domain.
- Use
->withVisitor($ip, $userAgent)so events are attributed to the visitor rather than your server. - Pass the page URL whenever you have one, since it is what the event is attributed to. Omitted (or blank), it falls back to the site home derived from
domain(https://example.com/): the ingest rejects a non-absolute URL outright, so an empty one would drop the event. - Fire-and-forget by default: transport errors are swallowed. Call
->strict()to get a client that throws on failure (handy in tests). - Pass
redactRoutes: [...]to the constructor to redact the page URL and same-origin referrer of every call, with the same patterns as the snippet. - Pass
redactUndecodablePaths: falseto decode a path that cannot be decoded (a malformed escape such as%ZZ, or invalid UTF-8 once decoded) tolerantly instead of masking it. It is then matched like any other path: a path matching a route goes out as its template (/orders/123%ZZbecomes/orders/[id]), and only a path matching no route goes out raw. The default,true, masks it as/[redacted]when a route is configured, as the browser SDK does. The browser SDK has no such option. - Pass
route: '/users/{id}'topageview()orevent()to send that call under a route template, or set a default for every call of a client with->withRoute('/users/{id}')(a string, or a closure returning one, resolved at send time). An explicitroute:wins over the default. In this mode a referrer is reduced to its origin (https://other.example/), whatever its host, and a referrer that is nothttp(s)is dropped. A blankroutecounts as absent and falls back to the default resolver. When a route template is configured but the resolver returns nothing, returns a blank string or throws, the page URL is never sent raw:redactRoutesapplies if one covers the path, otherwise the URL goes out as its origin and/[redacted], without query or hash. A page URL that cannot be parsed (a relative URL, an invalid port) goes out with an emptyuwhenever a masking option is configured (redactRoutesor a route template), which the ingest refuses, so the real path never leaves. A URL thatparse_urland a browser may read differently (a backslash, a tab, a line break, another control character, leading or trailing spaces, user information such asuser@host, or a host with any character other than letters, digits,.,-and_, such as a space, a sub-delimiter like;or!, a non-ASCII character like a full-width/,?,:or a no-break space; an IPv6 literal in brackets is accepted) is treated the same way: the page goes out with an emptyu, and a referrer is dropped. Nothing is normalised. Browsers send an internationalised host in punycode (xn--...), which passes. A backslash is refused even in the query string (?q=a\b): this is a deliberate false positive that fails closed. In routes mode only a path that matches a route is masked: a path that no route covers is sent as it is. Without any masking option it is sent unchanged. - The PSR-18 HTTP client and PSR-17 factories are auto-discovered (
php-http/discovery). You may also inject your own.
Wire payload
Events are posted as JSON with compact keys: n (name), d (domain), u (url), r (referrer), p (props) and $ (revenue). Screen width is not sent server-side. u must be an absolute http(s) URL; r may be empty (direct access).
License
MIT