First commit

This commit is contained in:
mathiaspalmqvist committed 2025-08-20 16:51:38 +02:00
1 parent 32125e4880
commit f2f9d9c4c1
14 files changed
+427 -140

No files matched your search

+49 -38
View File
@@ -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
[<img src="https://github-ads.s3.eu-central-1.amazonaws.com/matomo-proxy-laravel.jpg?t=1" width="419px" />](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
+2 -5
View File
@@ -63,12 +63,9 @@
"laravel": {
"providers": [
"MattitjaAB\\MatomoProxyLaravel\\MatomoProxyLaravelServiceProvider"
],
"aliases": {
"MatomoProxyLaravel": "MattitjaAB\\MatomoProxyLaravel\\Facades\\MatomoProxyLaravel"
}
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
}
+23 -1
View File
@@ -1,6 +1,28 @@
<?php
// config for MattitjaAB/MatomoProxyLaravel
return [
// Enable/disable tracking
'enabled' => 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
],
];
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace MattitjaAB\MatomoProxyLaravel\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/*
class ModelFactory extends Factory
{
protected $model = YourModel::class;
public function definition()
{
return [
];
}
}
*/
@@ -1,19 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('matomo_proxy_laravel_table', function (Blueprint $table) {
$table->id();
// add fields
$table->timestamps();
});
}
};
+1 -2
View File
@@ -5,8 +5,7 @@ parameters:
level: 5
paths:
- src
- config
- database
tmpDir: build/phpstan
checkOctaneCompatibility: true
checkModelProperties: true
View File
Whitespace-only changes.
@@ -1,19 +0,0 @@
<?php
namespace MattitjaAB\MatomoProxyLaravel\Commands;
use Illuminate\Console\Command;
class MatomoProxyLaravelCommand extends Command
{
public $signature = 'matomo-proxy-laravel';
public $description = 'My command';
public function handle(): int
{
$this->comment('All done');
return self::SUCCESS;
}
}
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace MattitjaAB\MatomoProxyLaravel\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @see \MattitjaAB\MatomoProxyLaravel\MatomoProxyLaravel
*/
class MatomoProxyLaravel extends Facade
{
protected static function getFacadeAccessor(): string
{
return \MattitjaAB\MatomoProxyLaravel\MatomoProxyLaravel::class;
}
}
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace MattitjaAB\MatomoProxyLaravel;
class MatomoProxyLaravel {}
+10 -11
View File
@@ -2,24 +2,23 @@
namespace MattitjaAB\MatomoProxyLaravel;
use MattitjaAB\MatomoProxyLaravel\Commands\MatomoProxyLaravelCommand;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use MattitjaAB\MatomoProxyLaravel\Middleware\MatomoProxyLaravelMiddleware;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
class MatomoProxyLaravelServiceProvider extends PackageServiceProvider
final class MatomoProxyLaravelServiceProvider extends PackageServiceProvider
{
public function configurePackage(Package $package): void
{
/*
* This class is a Package Service Provider
*
* More info: https://github.com/spatie/laravel-package-tools
*/
$package
->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);
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace MattitjaAB\MatomoProxyLaravel\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
final class MatomoProxyLaravelMiddleware
{
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
// Only track GET requests
if (! $request->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
}
}
}
-5
View File
@@ -1,5 +0,0 @@
<?php
it('can test', function () {
expect(true)->toBeTrue();
});
+172
View File
@@ -0,0 +1,172 @@
<?php
use Illuminate\Http\Client\Request as HttpClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use MattitjaAB\MatomoProxyLaravel\Middleware\MatomoProxyLaravelMiddleware;
it('sends a tracking hit and includes user id when authenticated', 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.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();
});