Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions config/prerender.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

return [
'enable' => 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',
],
];
38 changes: 38 additions & 0 deletions src/LaravelPrerenderServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Prerender\Laravel;

use GuzzleHttp\Client;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\ServiceProvider;

class LaravelPrerenderServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->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);
});
}
}
124 changes: 124 additions & 0 deletions src/PrerenderMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

namespace Prerender\Laravel;

use Closure;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Psr\Http\Message\ResponseInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\HttpFoundation\Response;

class PrerenderMiddleware
{
private bool $returnSoftHttpCodes;
private bool $useFullURL;
private string $prerenderUrl;
private ?string $prerenderToken;
private array $crawlerUserAgents;
private array $whitelist;
private array $blacklist;

public function __construct(private readonly Client $client)
{
$config = config('prerender');
$this->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))
);
}
}
Loading