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
40 changes: 40 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Test

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: none
tools: composer:v2

- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }}
restore-keys: ${{ runner.os }}-php-${{ matrix.php-version }}-

- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction

- name: Run tests
run: ./vendor/bin/pest
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',
],
];
19 changes: 19 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Tests">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
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);
});
}
}
129 changes: 129 additions & 0 deletions src/PrerenderMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?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
{
public const VERSION = '1.0.0';

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;
}
$headers['X-Prerender-Int-Type'] = 'Laravel';
$headers['X-Prerender-Int-Version'] = self::VERSION;
$headers['X-Prerender-Request-Id'] = (string) Str::uuid();

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
Loading