Accept text/plain events from the Plausible tracker
dependabot-auto-merge / dependabot (pull_request_target) Skipped
Fix PHP code style issues / php-code-styling (push) Successful in 6s
PHPStan / phpstan (push) Successful in 2m38s
run-tests / P8.3 - L11.* - prefer-lowest - ubuntu-latest (push) Failing after 33s
run-tests / P8.4 - L11.* - prefer-lowest - ubuntu-latest (push) Canceled after 0s
run-tests / P8.4 - L11.* - prefer-stable - ubuntu-latest (push) Canceled after 0s
run-tests / P8.3 - L12.* - prefer-lowest - ubuntu-latest (push) Canceled after 0s
run-tests / P8.3 - L12.* - prefer-stable - ubuntu-latest (push) Canceled after 0s
run-tests / P8.4 - L12.* - prefer-lowest - ubuntu-latest (push) Canceled after 0s
run-tests / P8.4 - L12.* - prefer-stable - ubuntu-latest (push) Canceled after 0s
run-tests / P8.3 - L13.* - prefer-lowest - ubuntu-latest (push) Canceled after 0s
run-tests / P8.3 - L13.* - prefer-stable - ubuntu-latest (push) Canceled after 0s
run-tests / P8.4 - L13.* - prefer-lowest - ubuntu-latest (push) Canceled after 0s
run-tests / P8.4 - L13.* - prefer-stable - ubuntu-latest (push) Canceled after 0s
run-tests / P8.3 - L11.* - prefer-stable - ubuntu-latest (push) Canceled after 1s

The Plausible tracker posts its JSON payload with Content-Type text/plain,
  which Laravel does not parse, so every real event failed validation and
  was redirected instead of being forwarded.

  - Decode the raw body when the request is not JSON
  - Respond with 400 instead of a redirect on invalid non-JSON requests
  - Trim trailing slash from the configured domain when forwarding events
  - Add a 5s timeout and respond with 502 when Plausible is unreachable
This commit is contained in:
mathiaspalmqvist committed 2026-11-12 09:18:50 +01:00
1 parent c1338fad8b
commit a97baf5921
3 files changed
+98 -4

No files matched your search

@@ -2,18 +2,30 @@
namespace Mattitja\LaravelPlausibleProxy\Http\Controllers;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Mattitja\LaravelPlausibleProxy\Http\Requests\PlausibleEventRequest;
class PlausibleEventApiController
{
public function __invoke(PlausibleEventRequest $request): Response
{
$upstream = Http::withHeaders([
'User-Agent' => (string) $request->userAgent(),
'X-Forwarded-For' => (string) $request->ip(),
])->post(config('plausible-proxy.domain').'/api/event', $request->all());
$url = rtrim((string) config('plausible-proxy.domain'), '/').'/api/event';
try {
$upstream = Http::timeout(5)
->withHeaders([
'User-Agent' => (string) $request->userAgent(),
'X-Forwarded-For' => (string) $request->ip(),
])
->post($url, $request->all());
} catch (ConnectionException $e) {
Log::error('Failed to forward Plausible event: '.$e->getMessage());
return response('Bad Gateway', 502);
}
return response($upstream->body(), $upstream->status())
->header('Content-Type', $upstream->header('Content-Type') ?: 'text/plain');
@@ -2,7 +2,9 @@
namespace Mattitja\LaravelPlausibleProxy\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class PlausibleEventRequest extends FormRequest
{
@@ -11,6 +13,32 @@ class PlausibleEventRequest extends FormRequest
return true;
}
/**
* The Plausible tracker sends its JSON payload with a text/plain content
* type, which Laravel does not parse automatically.
*/
protected function prepareForValidation(): void
{
if ($this->isJson()) {
return;
}
$payload = json_decode($this->getContent(), true);
if (is_array($payload)) {
$this->merge($payload);
}
}
protected function failedValidation(Validator $validator): void
{
if (! $this->expectsJson()) {
throw new HttpResponseException(response('Bad Request', 400));
}
parent::failedValidation($validator);
}
public function rules(): array
{
return [