From 170e974aa69cecfef77cf84c04eeb3cf8577fb62 Mon Sep 17 00:00:00 2001 From: Laszlo Takacs Date: Mon, 20 Apr 2026 16:03:53 +0200 Subject: [PATCH] Add Laravel middleware --- README.md | 71 +++++++++++++ composer.json | 49 +++++++++ config/prerender.php | 30 ++++++ src/LaravelPrerenderServiceProvider.php | 38 +++++++ src/PrerenderMiddleware.php | 124 ++++++++++++++++++++++ tests/Feature/PrerenderMiddlewareTest.php | 99 +++++++++++++++++ tests/Pest.php | 3 + tests/TestCase.php | 14 +++ 8 files changed, 428 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/prerender.php create mode 100644 src/LaravelPrerenderServiceProvider.php create mode 100644 src/PrerenderMiddleware.php create mode 100644 tests/Feature/PrerenderMiddlewareTest.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..c12500f --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# prerender-laravel + +Laravel middleware for [Prerender.io](https://prerender.io). Intercepts requests from bots and crawlers and serves prerendered HTML, so your JavaScript-rendered app is fully indexable by search engines and social media scrapers. + +Compatible with **Laravel 11+** and **PHP 8.2+**. + +## Installation + +```bash +composer require prerender/laravel-prerender +``` + +Publish the config file: + +```bash +php artisan vendor:publish --tag=prerender-config +``` + +## Setup + +Add your token to `.env`: + +```env +PRERENDER_TOKEN=your-token +``` + +The middleware registers itself automatically via the service provider. + +## Configuration + +| Key | Env var | Default | Description | +|-----|---------|---------|-------------| +| `enable` | `PRERENDER_ENABLE` | `true` | Disable entirely (e.g. local dev) | +| `prerender_url` | `PRERENDER_SERVICE_URL` | `https://service.prerender.io` | Service URL (override for self-hosted) | +| `prerender_token` | `PRERENDER_TOKEN` | `null` | Your Prerender.io token | +| `prerender_soft_http_codes` | `PRERENDER_SOFT_HTTP_STATUS_CODES` | `true` | Pass 3xx/404 codes through as-is | +| `full_url` | `PRERENDER_FULL_URL` | `false` | Send full URL including query string | +| `timeout` | `PRERENDER_TIMEOUT` | `0` | Guzzle timeout in seconds (0 = none) | + +### Whitelist / Blacklist + +Only prerender URLs matching the whitelist (empty = all URLs pass): + +```php +'whitelist' => ['/blog/*', '/product/*'], +``` + +Never prerender URLs matching the blacklist (static assets are blacklisted by default): + +```php +'blacklist' => ['*.js', '*.css', '/admin/*'], +``` + +Patterns support `*` wildcards. + +## How it works + +Requests are prerendered when **all** of the following are true: + +- The HTTP method is `GET` +- The `User-Agent` matches a known bot/crawler (Googlebot, Bingbot, Twitterbot, GPTBot, ClaudeBot, etc.) + — OR the URL contains `_escaped_fragment_` + — OR the `X-BUFFERBOT` header is present +- The URI is not blacklisted (static assets are excluded by default) +- The URI matches the whitelist (if configured) + +If the Prerender service is unreachable, the middleware falls back gracefully. + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..67fc50f --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "prerender/laravel-prerender", + "description": "Laravel middleware for prerendering JavaScript-rendered pages via Prerender.io", + "keywords": ["laravel", "prerender", "prerender.io", "seo", "middleware"], + "homepage": "https://github.com/prerender/integrations", + "license": "MIT", + "authors": [ + { + "name": "Prerender.io", + "homepage": "https://prerender.io" + } + ], + "require": { + "php": "^8.2", + "guzzlehttp/guzzle": "^7.8", + "illuminate/contracts": "^11.0|^12.0", + "symfony/psr-http-message-bridge": "^7.0" + }, + "require-dev": { + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0" + }, + "autoload": { + "psr-4": { + "Prerender\\Laravel\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Prerender\\Laravel\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Prerender\\Laravel\\LaravelPrerenderServiceProvider" + ] + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/prerender.php b/config/prerender.php new file mode 100644 index 0000000..b303095 --- /dev/null +++ b/config/prerender.php @@ -0,0 +1,30 @@ + env('PRERENDER_ENABLE', true), + 'prerender_url' => env('PRERENDER_SERVICE_URL', 'https://service.prerender.io'), + 'prerender_token' => env('PRERENDER_TOKEN'), + 'prerender_soft_http_codes' => env('PRERENDER_SOFT_HTTP_STATUS_CODES', true), + 'full_url' => env('PRERENDER_FULL_URL', false), + 'timeout' => env('PRERENDER_TIMEOUT', 0), + + 'whitelist' => [], + + 'blacklist' => [ + '*.js', '*.css', '*.xml', '*.less', '*.png', '*.jpg', '*.jpeg', + '*.gif', '*.pdf', '*.doc', '*.txt', '*.ico', '*.rss', '*.zip', + '*.mp3', '*.rar', '*.exe', '*.wmv', '*.avi', '*.ppt', '*.mpg', + '*.mpeg', '*.tif', '*.wav', '*.mov', '*.psd', '*.ai', '*.xls', + '*.mp4', '*.m4a', '*.swf', '*.dat', '*.dmg', '*.iso', '*.flv', + '*.m4v', '*.torrent', '*.ttf', '*.woff', '*.woff2', '*.svg', + ], + + 'crawler_user_agents' => [ + 'googlebot', 'yahoo', 'bingbot', 'baiduspider', 'yandex', + 'facebookexternalhit', 'twitterbot', 'rogerbot', 'linkedinbot', + 'embedly', 'quora link preview', 'showyoubot', 'outbrain', + 'pinterest', 'slackbot', 'w3c_validator', 'redditbot', 'applebot', + 'discordbot', 'perplexity', 'oai-searchbot', 'chatgpt-user', + 'gptbot', 'claudebot', 'amazonbot', + ], +]; diff --git a/src/LaravelPrerenderServiceProvider.php b/src/LaravelPrerenderServiceProvider.php new file mode 100644 index 0000000..bd886a7 --- /dev/null +++ b/src/LaravelPrerenderServiceProvider.php @@ -0,0 +1,38 @@ +publishes([ + __DIR__ . '/../config/prerender.php' => config_path('prerender.php'), + ], 'prerender-config'); + + if (! config('prerender.enable', true)) { + return; + } + + $this->app->make(Kernel::class)->pushMiddleware(PrerenderMiddleware::class); + } + + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../config/prerender.php', 'prerender'); + + $this->app->when(PrerenderMiddleware::class) + ->needs(Client::class) + ->give(function () { + $options = ['timeout' => config('prerender.timeout', 0)]; + if (! config('prerender.prerender_soft_http_codes', true)) { + $options['allow_redirects'] = false; + } + return new Client($options); + }); + } +} diff --git a/src/PrerenderMiddleware.php b/src/PrerenderMiddleware.php new file mode 100644 index 0000000..616c99c --- /dev/null +++ b/src/PrerenderMiddleware.php @@ -0,0 +1,124 @@ +prerenderUrl = $config['prerender_url']; + $this->prerenderToken = $config['prerender_token'] ?: null; + $this->returnSoftHttpCodes = (bool) $config['prerender_soft_http_codes']; + $this->useFullURL = (bool) $config['full_url']; + $this->crawlerUserAgents = $config['crawler_user_agents']; + $this->whitelist = $config['whitelist']; + $this->blacklist = $config['blacklist']; + } + + public function handle(Request $request, Closure $next): mixed + { + if (! $this->shouldShowPrerenderedPage($request)) { + return $next($request); + } + + $prerenderResponse = $this->getPrerenderedPageResponse($request); + + if (! $prerenderResponse) { + return $next($request); + } + + $statusCode = $prerenderResponse->getStatusCode(); + + if (! $this->returnSoftHttpCodes && $statusCode >= 300 && $statusCode < 400) { + $location = array_change_key_case($prerenderResponse->getHeaders(), CASE_LOWER)['location'][0] ?? '/'; + return redirect($location, $statusCode); + } + + return (new HttpFoundationFactory)->createResponse($prerenderResponse); + } + + private function shouldShowPrerenderedPage(Request $request): bool + { + if (! $request->isMethod('GET')) return false; + + $userAgent = strtolower($request->server->get('HTTP_USER_AGENT', '')); + if (empty($userAgent)) return false; + + if (! $this->isEligibleForPrerender($request, $userAgent)) return false; + + if ($this->whitelist && ! $this->isListed($request->getRequestUri(), $this->whitelist)) { + return false; + } + + $uris = array_values(array_filter([$request->getRequestUri(), $request->headers->get('Referer')])); + if ($this->blacklist && $this->isListed($uris, $this->blacklist)) return false; + + return true; + } + + private function isEligibleForPrerender(Request $request, string $userAgent): bool + { + if ($request->query->has('_escaped_fragment_')) return true; + if ($request->server->get('X-BUFFERBOT')) return true; + return collect($this->crawlerUserAgents) + ->contains(fn ($agent) => Str::contains($userAgent, strtolower($agent))); + } + + private function getPrerenderedPageResponse(Request $request): ?ResponseInterface + { + $headers = ['User-Agent' => $request->server->get('HTTP_USER_AGENT')]; + if ($this->prerenderToken) { + $headers['X-Prerender-Token'] = $this->prerenderToken; + } + + try { + return $this->client->get($this->buildApiUrl($request), compact('headers')); + } catch (RequestException $e) { + if (! $this->returnSoftHttpCodes && $e->getResponse()?->getStatusCode() === 404) { + abort(404); + } + return null; + } catch (ConnectException) { + return null; + } + } + + private function buildApiUrl(Request $request): string + { + return rtrim($this->prerenderUrl, '/') . '/' . $this->generatePrerenderUrl($request); + } + + private function generatePrerenderUrl(Request $request): string + { + if ($this->useFullURL) { + return $request->fullUrl(); + } + return $request->getScheme() . '://' . $request->getHost() . $request->getRequestUri(); + } + + private function isListed(string|array $needles, array $list): bool + { + return collect($list)->contains( + fn ($pattern) => collect((array) $needles)->contains(fn ($needle) => Str::is($pattern, $needle)) + ); + } +} diff --git a/tests/Feature/PrerenderMiddlewareTest.php b/tests/Feature/PrerenderMiddlewareTest.php new file mode 100644 index 0000000..fa89c64 --- /dev/null +++ b/tests/Feature/PrerenderMiddlewareTest.php @@ -0,0 +1,99 @@ +prerendered'; + +function makeMiddleware(array $guzzleResponses = []): PrerenderMiddleware +{ + config([ + 'prerender.prerender_url' => 'https://service.prerender.io', + 'prerender.prerender_token' => null, + 'prerender.prerender_soft_http_codes' => true, + 'prerender.full_url' => false, + 'prerender.timeout' => 0, + 'prerender.whitelist' => [], + 'prerender.blacklist' => ['*.js', '*.css', '*.png'], + 'prerender.crawler_user_agents' => ['googlebot', 'bingbot', 'twitterbot'], + ]); + + $client = new Client(['handler' => HandlerStack::create(new MockHandler($guzzleResponses))]); + return new PrerenderMiddleware($client); +} + +it('passes browser requests through', function () { + $middleware = makeMiddleware(); + $request = Request::create('/', 'GET', [], [], [], ['HTTP_USER_AGENT' => BROWSER_UA]); + + $response = $middleware->handle($request, fn () => response('normal response')); + + expect($response->getContent())->toBe('normal response'); +}); + +it('returns prerendered response for bot UA', function () { + $middleware = makeMiddleware([new Response(200, [], PRERENDERED_HTML)]); + $request = Request::create('/about', 'GET', [], [], [], ['HTTP_USER_AGENT' => BOT_UA]); + + $response = $middleware->handle($request, fn () => response('normal response')); + + expect($response->getContent())->toBe(PRERENDERED_HTML); + expect($response->getStatusCode())->toBe(200); +}); + +it('passes static assets through even with bot UA', function () { + $middleware = makeMiddleware(); + $request = Request::create('/app.js', 'GET', [], [], [], ['HTTP_USER_AGENT' => BOT_UA]); + + $response = $middleware->handle($request, fn () => response('normal response')); + + expect($response->getContent())->toBe('normal response'); +}); + +it('prerenders when _escaped_fragment_ is present', function () { + $middleware = makeMiddleware([new Response(200, [], PRERENDERED_HTML)]); + $request = Request::create('/?_escaped_fragment_=', 'GET', [], [], [], ['HTTP_USER_AGENT' => BROWSER_UA]); + + $response = $middleware->handle($request, fn () => response('normal response')); + + expect($response->getContent())->toBe(PRERENDERED_HTML); +}); + +it('prerenders when X-BUFFERBOT header is present', function () { + $middleware = makeMiddleware([new Response(200, [], PRERENDERED_HTML)]); + $request = Request::create('/', 'GET', [], [], [], [ + 'HTTP_USER_AGENT' => BROWSER_UA, + 'X-BUFFERBOT' => 'true', + ]); + + $response = $middleware->handle($request, fn () => response('normal response')); + + expect($response->getContent())->toBe(PRERENDERED_HTML); +}); + +it('passes POST requests through', function () { + $middleware = makeMiddleware(); + $request = Request::create('/', 'POST', [], [], [], ['HTTP_USER_AGENT' => BOT_UA]); + + $response = $middleware->handle($request, fn () => response('normal response')); + + expect($response->getContent())->toBe('normal response'); +}); + +it('falls back gracefully on connection error', function () { + $error = new ConnectException('connection refused', new GuzzleRequest('GET', '/')); + $middleware = makeMiddleware([$error]); + $request = Request::create('/', 'GET', [], [], [], ['HTTP_USER_AGENT' => BOT_UA]); + + $response = $middleware->handle($request, fn () => response('normal response')); + + expect($response->getContent())->toBe('normal response'); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..102f0d1 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,3 @@ +in('Feature'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..5625d32 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,14 @@ +