PHPackages                             rushing/saloon-playwright-sender - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. rushing/saloon-playwright-sender

ActiveLibrary[HTTP &amp; Networking](/categories/http)

rushing/saloon-playwright-sender
================================

A Saloon Sender that dispatches requests through a Playwright browser microservice

00PHP

Since Jun 9Pushed 1mo agoCompare

[ Source](https://github.com/stephenr85/saloon-playwright-sender)[ Packagist](https://packagist.org/packages/rushing/saloon-playwright-sender)[ RSS](/packages/rushing-saloon-playwright-sender/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependenciesVersions (1)Used By (0)

saloon-playwright-sender
========================

[](#saloon-playwright-sender)

A [Saloon](https://docs.saloon.dev) `Sender` that dispatches requests through a Playwright browser microservice. Use it when a target site requires JavaScript rendering, bot-detection bypass, or any browser interaction before the page is ready to scrape.

How it works
------------

[](#how-it-works)

1. Your Saloon connector sends a request through `PlaywrightSender` instead of the default Guzzle sender.
2. `PlaywrightSender` forwards the request to a local Node.js service.
3. The Node.js service uses a Playwright browser to navigate to the URL, optionally runs an interaction script, then returns the rendered HTML (or raw response body) to PHP.
4. Saloon receives a normal `Response` — DTOs, plugins, retries, and `MockClient` all work as expected.

Requirements
------------

[](#requirements)

- PHP 8.2+
- [Saloon v3](https://docs.saloon.dev)
- Node.js 18+

Installation
------------

[](#installation)

```
composer require rushing/saloon-playwright-sender
```

If you are using Laravel, publish the config:

```
php artisan vendor:publish --tag=playwright-sender-config
```

Starting the Playwright service
-------------------------------

[](#starting-the-playwright-service)

```
cd vendor/rushing/saloon-playwright-sender/playwright-service
yarn install          # or: npm install
npx playwright install chromium
node index.js
```

The service binds to `127.0.0.1` (loopback only) by default and is not reachable from outside the host.

> **Tip — Laravel projects:** add the install steps to your `composer.json` `post-autoload-dump` scripts so they run automatically on `composer install` / `composer update`:
>
> ```
> "post-autoload-dump": [
>     "...",
>     "yarn --cwd vendor/rushing/saloon-playwright-sender/playwright-service install --silent",
>     "@php -r \"passthru('cd vendor/rushing/saloon-playwright-sender/playwright-service && npx playwright install chromium --quiet 2>&1');\""
> ]
> ```
>
>
>
> Set `PLAYWRIGHT_AUTO_START=true` in `.env.testing` so the service starts automatically during test runs.

### Bot-detection bypass

[](#bot-detection-bypass)

The service launches Chrome (preferred, via `channel: 'chrome'`) or the downloaded Chromium headless shell as a fallback. It uses a **persistent browser profile** (`~/.playwright-sender-profile` by default) so that bot-detection clearance cookies survive between process restarts.

To pass Cloudflare and similar bot checks, the service:

- Uses `--disable-blink-features=AutomationControlled`
- Masks `navigator.webdriver` via an init script
- Launches in headed mode by default (`PLAYWRIGHT_HEADLESS=true` to override)
- Retries once after a short wait on `429 Too Many Requests`

> **Headless vs. headed on a server.** Headed mode passes fingerprint checks that headless Chromium fails, so the default is headed. On a server with **no display** (CI, a headless VM), run the *headed* browser under a virtual display: `xvfb-run -a node index.js`. `xvfb-run` is only needed for headed mode — if you set `PLAYWRIGHT_HEADLESS=true`, no display (and no Xvfb) is required, but expect more bot blocks.

The `waitUntil` strategy defaults to `'load'` rather than `'networkidle'` so pages with persistent analytics requests don't time out. Override with `PLAYWRIGHT_WAIT_UNTIL`.

### Environment variables (Node service)

[](#environment-variables-node-service)

VariableDefaultDescription`PORT``3000`Port the service listens on`HOST``127.0.0.1`Interface to bind — keep as loopback`PLAYWRIGHT_HEADLESS`*(headed)*`true` runs headless (no display needed). Headed (default) needs a display — on a headless server wrap with `xvfb-run``PLAYWRIGHT_USER_DATA_DIR``~/.playwright-sender-profile`Persistent browser profile directory`PLAYWRIGHT_WAIT_UNTIL``load`Playwright `waitUntil` navigation strategyConfiguration
-------------

[](#configuration)

### PHP / Laravel

[](#php--laravel)

Set these in `.env` (or pass a `PlaywrightServiceConfig` directly — see below):

VariableDefaultDescription`PLAYWRIGHT_SERVICE_URL``http://localhost:3000`URL of the running Node service`PLAYWRIGHT_TIMEOUT``30`Request timeout in seconds`PLAYWRIGHT_RESPONSE_MODE``html``html` returns rendered DOM; `body` returns the raw HTTP response body`PLAYWRIGHT_AUTO_START``false`Start the Node service automatically if it is not already runningBasic usage
-----------

[](#basic-usage)

Set `PlaywrightSender` as the default sender on any Saloon connector:

```
use Rushing\SaloonPlaywright\PlaywrightSender;
use Saloon\Contracts\Sender;
use Saloon\Http\Connector;

class MyConnector extends Connector
{
    public function resolveBaseUrl(): string
    {
        return 'https://example.com';
    }

    protected function defaultSender(): Sender
    {
        return new PlaywrightSender();
    }
}
```

To override the service URL or timeout at runtime:

```
use Rushing\SaloonPlaywright\PlaywrightServiceConfig;

protected function defaultSender(): Sender
{
    return new PlaywrightSender(new PlaywrightServiceConfig(
        serviceUrl: 'http://playwright-service:3000',
        timeout: 60,
        responseMode: 'html',
    ));
}
```

Parse the rendered HTML in your request's `createDtoFromResponse()` using Saloon's built-in `$response->dom()`, which returns a [`Symfony\Component\DomCrawler\Crawler`](https://symfony.com/doc/current/components/dom_crawler.html):

```
public function createDtoFromResponse(Response $response): array
{
    $items = [];

    $response->dom()->filter('.product-card')->each(function (Crawler $node) use (&$items) {
        $items[] = [
            'name'  => trim($node->filter('h2')->text()),
            'price' => trim($node->filter('.price')->text()),
        ];
    });

    return $items;
}
```

Browser interactions
--------------------

[](#browser-interactions)

### `playwright_script` — post-load interactions

[](#playwright_script--post-load-interactions)

Some pages require an action before their full content is available — expanding a collapsed section, clicking a "load more" button, or waiting for a lazy-loaded element.

Define a `playwright_script` in your request's `defaultConfig()`. The script runs **after the page loads successfully** and has access to the full [Playwright `page` API](https://playwright.dev/docs/api/class-page):

```
public function defaultConfig(): array
{
    return [
        'playwright_script' => "
            const btn = await page.$('.load-more');
            if (btn) {
                await btn.click();
                await page.waitForLoadState('networkidle');
            }
        ",
    ];
}
```

The script runs as an `async` function body — `await` works at the top level. The `page` variable is the Playwright `Page` object.

### Common patterns

[](#common-patterns)

**Wait for an element to appear before capturing:**

```
await page.waitForSelector('.results-container');
```

**Scroll to the bottom to trigger lazy loading:**

```
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForLoadState('networkidle');
```

**Dismiss a cookie banner:**

```
const dismiss = await page.$('[data-testid="accept-cookies"]');
if (dismiss) await dismiss.click();
```

**Multiple sequential interactions:**

```
await page.click('#expand-section');
await page.waitForSelector('.expanded-content');
await page.click('.load-all-results');
await page.waitForLoadState('networkidle');
```

### `challenge_script` — bot-detection challenge handling

[](#challenge_script--bot-detection-challenge-handling)

Some sites return a `429 Too Many Requests` with an interactive challenge page (e.g. a DataDome slider) that must be solved before the real content loads.

Define a `challenge_script` in your request's `defaultConfig()`. The script runs **on the 429 challenge page** instead of the passive 4-second retry:

```
public function defaultConfig(): array
{
    return [
        'challenge_script' => "
            // Wait for the challenge component to render
            const handle = await page.waitForSelector(
                '.challenge-slider-handle, [role=\"slider\"]',
                { timeout: 10000 }
            ).catch(() => null);

            if (!handle) return;

            const handleBox = await handle.boundingBox();
            const track = await page.$('.challenge-slider-track');
            const trackBox = track ? await track.boundingBox() : null;

            const startX = handleBox.x + handleBox.width / 2;
            const startY = handleBox.y + handleBox.height / 2;
            const endX = trackBox ? trackBox.x + trackBox.width - handleBox.width / 2 : startX + 280;

            await page.mouse.move(startX, startY, { steps: 3 });
            await page.mouse.down();
            const steps = 40;
            for (let i = 1; i
