diff --git a/README.md b/README.md index c687de5..8cc06d7 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,80 @@ -# This is my package matomo-proxy-laravel +# Matomo Proxy for Laravel [![Latest Version on Packagist](https://img.shields.io/packagist/v/mattitjaab/matomo-proxy-laravel.svg?style=flat-square)](https://packagist.org/packages/mattitjaab/matomo-proxy-laravel) [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/mattitjaab/matomo-proxy-laravel/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/mattitjaab/matomo-proxy-laravel/actions?query=workflow%3Arun-tests+branch%3Amain) [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/mattitjaab/matomo-proxy-laravel/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/mattitjaab/matomo-proxy-laravel/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) [![Total Downloads](https://img.shields.io/packagist/dt/mattitjaab/matomo-proxy-laravel.svg?style=flat-square)](https://packagist.org/packages/mattitjaab/matomo-proxy-laravel) -This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. - -## Support us - -[](https://spatie.be/github-ad-click/matomo-proxy-laravel) - -We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). - -We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). +This package provides a plug-and-play **server-side Matomo tracking middleware** for Laravel. +Instead of embedding JavaScript trackers in your frontend, requests are tracked directly from your Laravel app to your Matomo instance, ensuring better privacy, ad-blocker resilience, and simplified integration. ## Installation -You can install the package via composer: +Install the package via Composer: ```bash composer require mattitjaab/matomo-proxy-laravel ``` -You can publish and run the migrations with: - -```bash -php artisan vendor:publish --tag="matomo-proxy-laravel-migrations" -php artisan migrate -``` - -You can publish the config file with: +Publish the config file: ```bash php artisan vendor:publish --tag="matomo-proxy-laravel-config" ``` -This is the contents of the published config file: +This will create a `config/matomo-proxy-laravel.php` file. Example: ```php return [ + 'enabled' => env('MATOMO_ENABLED', true), + + // Full Matomo tracking endpoint (must include /matomo.php) + 'base_url' => env('MATOMO_BASE_URL', ''), + + // Matomo site ID + 'site_id' => env('MATOMO_SITE_ID', null), + + // Optional token_auth (can be null for anonymous tracking) + 'token' => env('MATOMO_TOKEN', null), + + // Paths that should never be tracked + 'ignore' => [ + 'telescope/*', 'horizon/*', 'health', '_debugbar/*', + ], + + // HTTP client behavior + 'timeout' => env('MATOMO_TIMEOUT', 2), + 'retry' => [ + 'times' => env('MATOMO_RETRY_TIMES', 0), + 'sleep' => env('MATOMO_RETRY_SLEEP_MS', 100), // milliseconds + ], ]; ``` -Optionally, you can publish the views using - -```bash -php artisan vendor:publish --tag="matomo-proxy-laravel-views" -``` - ## Usage +Register the middleware globally in your `bootstrap/app.php`: + ```php -$matomoProxyLaravel = new MattitjaAB\MatomoProxyLaravel(); -echo $matomoProxyLaravel->echoPhrase('Hello, MattitjaAB!'); +->withMiddleware(function (Middleware $middleware): void { + $middleware->append(\MattitjaAB\MatomoProxyLaravel\Middleware\MatomoProxyLaravelMiddleware::class); +}) ``` +2. Configure your `.env` file: + +```dotenv +MATOMO_ENABLED=true +MATOMO_BASE_URL=https://analytics.example.com/matomo.php +MATOMO_SITE_ID=1 +MATOMO_TOKEN=null +MATOMO_TIMEOUT=2 +MATOMO_RETRY_TIMES=0 +MATOMO_RETRY_SLEEP_MS=100 +``` + +✅ That’s it. All requests will now be tracked server-side in Matomo without requiring any frontend JS snippet. + ## Testing ```bash @@ -66,18 +85,10 @@ composer test Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. -## Contributing - -Please see [CONTRIBUTING](CONTRIBUTING.md) for details. - -## Security Vulnerabilities - -Please review [our security policy](../../security/policy) on how to report security vulnerabilities. - ## Credits -- [MattitjaAB](https://github.com/MattitjaAB) -- [All Contributors](../../contributors) +* [MattitjaAB](https://github.com/MattitjaAB) +* [All Contributors](../../contributors) ## License diff --git a/composer.json b/composer.json index 08ecd47..5d9c6e5 100644 --- a/composer.json +++ b/composer.json @@ -63,12 +63,9 @@ "laravel": { "providers": [ "MattitjaAB\\MatomoProxyLaravel\\MatomoProxyLaravelServiceProvider" - ], - "aliases": { - "MatomoProxyLaravel": "MattitjaAB\\MatomoProxyLaravel\\Facades\\MatomoProxyLaravel" - } + ] } }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/config/matomo-proxy-laravel.php b/config/matomo-proxy-laravel.php index e059b46..3bd551d 100644 --- a/config/matomo-proxy-laravel.php +++ b/config/matomo-proxy-laravel.php @@ -1,6 +1,28 @@ env('MATOMO_ENABLED', true), + // Full Matomo tracking endpoint (must include /matomo.php) + // Example: https://analytics.example.com/matomo.php + 'base_url' => env('MATOMO_BASE_URL', ''), + + // Matomo site ID + 'site_id' => env('MATOMO_SITE_ID', null), + + // Optional token_auth (can be null for anonymous tracking) + 'token' => env('MATOMO_TOKEN', null), + + // Paths that should never be tracked + 'ignore' => [ + 'telescope/*', 'horizon/*', 'health', '_debugbar/*', + ], + + // HTTP client behavior + 'timeout' => env('MATOMO_TIMEOUT', 2), + 'retry' => [ + 'times' => env('MATOMO_RETRY_TIMES', 0), + 'sleep' => env('MATOMO_RETRY_SLEEP_MS', 100), // milliseconds + ], ]; diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php deleted file mode 100644 index b1b5d3e..0000000 --- a/database/factories/ModelFactory.php +++ /dev/null @@ -1,19 +0,0 @@ -id(); - - // add fields - - $table->timestamps(); - }); - } -}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ab1b4c3..efd7b48 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,8 +5,7 @@ parameters: level: 5 paths: - src - - config - - database tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true + diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Commands/MatomoProxyLaravelCommand.php b/src/Commands/MatomoProxyLaravelCommand.php deleted file mode 100644 index 9110cbb..0000000 --- a/src/Commands/MatomoProxyLaravelCommand.php +++ /dev/null @@ -1,19 +0,0 @@ -comment('All done'); - - return self::SUCCESS; - } -} diff --git a/src/Facades/MatomoProxyLaravel.php b/src/Facades/MatomoProxyLaravel.php deleted file mode 100644 index d88ad8e..0000000 --- a/src/Facades/MatomoProxyLaravel.php +++ /dev/null @@ -1,16 +0,0 @@ -name('matomo-proxy-laravel') - ->hasConfigFile() - ->hasViews() - ->hasMigration('create_matomo_proxy_laravel_table') - ->hasCommand(MatomoProxyLaravelCommand::class); + ->hasConfigFile('matomo-proxy-laravel'); + } + + public function packageBooted(): void + { + $this->app->make(HttpKernel::class) + ->pushMiddleware(MatomoProxyLaravelMiddleware::class); } } diff --git a/src/Middleware/MatomoProxyLaravelMiddleware.php b/src/Middleware/MatomoProxyLaravelMiddleware.php new file mode 100644 index 0000000..1f87a40 --- /dev/null +++ b/src/Middleware/MatomoProxyLaravelMiddleware.php @@ -0,0 +1,170 @@ +isMethod('GET')) { + return $response; + } + + // Global feature toggle + required config + if (! $this->isEnabled()) { + return $response; + } + + // Only track HTML responses + if (! $this->isHtmlResponse($response)) { + return $response; + } + + // Respect ignore patterns + if ($this->isIgnoredPath($request)) { + return $response; + } + + // Skip non-success/redirect responses (e.g., 404, 5xx) + if (! $this->isTrackableStatus($response)) { + return $response; + } + + // Ensure endpoint is present + $endpoint = $this->buildEndpoint(); + if ($endpoint === null) { + return $response; + } + + // Build payload now; do not capture Request in any deferred closure + $params = $this->buildParams($request); + + // Send synchronously (you can swap to app()->terminating() if desired) + $this->send($endpoint, $params); + + return $response; + } + + /** + * Enabled flag and required keys. + */ + private function isEnabled(): bool + { + return config('matomo-proxy-laravel.enabled') === true + && filled(config('matomo-proxy-laravel.base_url')) + && filled(config('matomo-proxy-laravel.site_id')); + } + + /** + * Only track HTML (also accept application/xhtml+xml). + */ + private function isHtmlResponse(Response $response): bool + { + $contentType = $response->headers->get('content-type'); + + return $contentType !== null + && (str_starts_with($contentType, 'text/html') + || str_starts_with($contentType, 'application/xhtml+xml')); + } + + /** + * Respect configurable ignore patterns. + */ + private function isIgnoredPath(Request $request): bool + { + foreach (config('matomo-proxy-laravel.ignore', []) as $pattern) { + if ($request->is($pattern)) { + return true; + } + } + + return false; + } + + /** + * Count only successful (2xx) and redirect (3xx) responses. + */ + private function isTrackableStatus(Response $response): bool + { + $code = $response->getStatusCode(); + + return $code >= 200 && $code < 400; + } + + /** + * Endpoint must already include /matomo.php. + */ + private function buildEndpoint(): ?string + { + $endpoint = config('matomo-proxy-laravel.base_url'); + + return filled($endpoint) ? trim((string) $endpoint) : null; + } + + /** + * Build Matomo Tracking API payload. + */ + private function buildParams(Request $request): array + { + $params = [ + 'idsite' => (string) config('matomo-proxy-laravel.site_id', ''), + 'rec' => 1, + 'apiv' => 1, + 'url' => $request->fullUrl(), + 'action_name' => $request->route()?->getName() ?: $request->path(), + 'ua' => $request->userAgent(), + ]; + + if ($ref = $request->headers->get('referer')) { + $params['urlref'] = $ref; + } + + if ($lang = $request->headers->get('accept-language')) { + $params['lang'] = $lang; + } + + if ($ip = $request->ip()) { + $params['cip'] = $ip; + } + + if ($token = trim((string) config('matomo-proxy-laravel.token', ''))) { + $params['token_auth'] = $token; + } + + if ($uid = $request->user()->id ?? null) { + $params['uid'] = (string) $uid; + } + + return $params; + } + + /** + * Perform the HTTP request (synchronous). + */ + private function send(string $endpoint, array $params): void + { + $timeout = (int) config('matomo-proxy-laravel.timeout', 2); + $retryTimes = (int) config('matomo-proxy-laravel.retry.times', 0); + $retrySleep = (int) config('matomo-proxy-laravel.retry.sleep.ms', 100); + + try { + Http::asForm() + ->timeout($timeout) + ->retry($retryTimes, $retrySleep) + ->post($endpoint, $params); + } catch (\Throwable) { + // Never let analytics affect the response lifecycle + } + } +} diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index 5d36321..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/MatomoTrackingMiddlewareTest.php b/tests/MatomoTrackingMiddlewareTest.php new file mode 100644 index 0000000..3d90dfe --- /dev/null +++ b/tests/MatomoTrackingMiddlewareTest.php @@ -0,0 +1,172 @@ +set('matomo-proxy-laravel.enabled', true); + config()->set('matomo-proxy-laravel.base_url', 'https://matomo.example/matomo.php'); + config()->set('matomo-proxy-laravel.site_id', '1'); + config()->set('matomo-proxy-laravel.token', 'secret-token'); + + Http::preventStrayRequests(); + Http::fake([ + 'https://matomo.example/matomo.php' => Http::response('OK', 200), + ]); + + $user = new class implements \Illuminate\Contracts\Auth\Authenticatable + { + public int $id = 123; + + public function getAuthIdentifierName() + { + return 'id'; + } + + public function getAuthIdentifier() + { + return $this->id; + } + + public function getAuthPasswordName() + { + return 'password'; + } + + public function getAuthPassword() + { + return ''; + } + + public function getRememberToken() + { + return null; + } + + public function setRememberToken($value): void {} + + public function getRememberTokenName() + { + return ''; + } + }; + + Route::get('/page', fn () => response('ok', 200, ['Content-Type' => 'text/html'])) + ->name('test.route'); + + $this->be($user); + + $this->get('/page', [ + 'Accept-Language' => 'sv-SE', + 'User-Agent' => 'PHPUnit', + 'Referer' => 'https://ref.example', + ])->assertOk(); + + Http::assertSent(function (HttpClientRequest $request) { + $params = $request->data(); + + return $request->url() === 'https://matomo.example/matomo.php' + && $request->method() === 'POST' + && ($params['idsite'] ?? null) === '1' + && ($params['uid'] ?? null) === '123' + && ($params['token_auth'] ?? null) === 'secret-token' + && ($params['action_name'] ?? null) === 'test.route' + && ($params['urlref'] ?? null) === 'https://ref.example' + && ($params['lang'] ?? null) === 'sv-SE' + && ($params['ua'] ?? null) === 'PHPUnit' + && ($params['rec'] ?? null) === 1 + && ($params['apiv'] ?? null) === 1; + }); + + Http::assertSentCount(1); +}); + +it('does nothing when disabled', function () { + config()->set('matomo-proxy-laravel.enabled', false); + config()->set('matomo-proxy-laravel.base_url', 'https://matomo.example/matomo.php'); + config()->set('matomo-proxy-laravel.site_id', '1'); + + Http::fake(); + + Route::get('/disabled', fn () => 'ok') + ->middleware(MatomoProxyLaravelMiddleware::class); + + $this->get('/disabled')->assertOk(); + + Http::assertNothingSent(); +}); + +it('never breaks the response when exceptions occur during tracking', function () { + config()->set('matomo-proxy-laravel.enabled', true); + config()->set('matomo-proxy-laravel.base_url', 'https://matomo.example/matomo.php'); + config()->set('matomo-proxy-laravel.site_id', '1'); + + Http::fake(function () { + throw new Exception('network error'); + }); + + Route::get('/exception', fn () => 'ok') + ->middleware(MatomoProxyLaravelMiddleware::class); + + $this->get('/exception')->assertOk(); +}); + +it('does not send tracking for non-get requests', function () { + config()->set('matomo-proxy-laravel.enabled', true); + config()->set('matomo-proxy-laravel.base_url', 'https://matomo.example/matomo.php'); + config()->set('matomo-proxy-laravel.site_id', '1'); + + Http::fake(); + + Route::post('/post', fn () => 'ok') + ->middleware(MatomoProxyLaravelMiddleware::class); + + $this->post('/post')->assertOk(); + + Http::assertNothingSent(); +}); + +it('does not send tracking for ignored paths', function () { + config()->set('matomo-proxy-laravel.enabled', true); + config()->set('matomo-proxy-laravel.base_url', 'https://matomo.example/matomo.php'); + config()->set('matomo-proxy-laravel.site_id', '1'); + config()->set('matomo-proxy-laravel.ignore', ['ignored*']); + + Http::fake(); + + Route::get('/ignored/page', fn () => 'ok') + ->middleware(MatomoProxyLaravelMiddleware::class); + + $this->get('/ignored/page')->assertOk(); + + Http::assertNothingSent(); +}); + +it('does not send tracking for non-html responses', function () { + config()->set('matomo-proxy-laravel.enabled', true); + config()->set('matomo-proxy-laravel.base_url', 'https://matomo.example/matomo.php'); + config()->set('matomo-proxy-laravel.site_id', '1'); + + Http::fake(); + + Route::get('/json', fn () => response()->json(['ok' => true])) + ->middleware(MatomoProxyLaravelMiddleware::class); + + $this->get('/json')->assertOk(); + + Http::assertNothingSent(); +}); + +it('does not send tracking for 404 responses', function () { + config()->set('matomo-proxy-laravel.enabled', true); + config()->set('matomo-proxy-laravel.base_url', 'https://matomo.example/matomo.php'); + config()->set('matomo-proxy-laravel.site_id', '1'); + + Http::fake(); + + $this->get('/nonexistent')->assertNotFound(); + + Http::assertNothingSent(); +});