nesk/puphpeteer
PuPHPeteer is a PHP bridge to Puppeteer that lets you drive headless Chrome from PHP. Scrape pages, generate screenshots and PDFs, run browser tests, and automate JavaScript-heavy sites with Puppeteer's full API, powered by Node.js via Rialto.
PuPHPeteer is a two-part install: a Composer package for PHP and a Node package for the bridge, plus a local Chrome/Chromium (or system Chrome via executablePath).
composer require nesk/puphpeteer
npm install @nesk/puphpeteer # the Node side of the bridge
Minimal script:
use Nesk\Puphpeteer\Puppeteer;
$puppeteer = new Puppeteer;
$browser = $puppeteer->launch(['args' => ['--no-sandbox']]);
try {
$page = $browser->newPage();
$page->setViewport(['width' => 1280, 'height' => 800, 'deviceScaleFactor' => 2]);
$page->goto('https://example.com', ['waitUntil' => 'networkidle0']);
$page->screenshot(['path' => storage_path('app/example.png'), 'fullPage' => true]);
} finally {
$browser->close();
}
Where to look first:
Nesk\Puphpeteer\Puppeteer — the entry point (constructor options, launch()).Nesk\Puphpeteer\Resources\* — Browser, Page, ElementHandle, Request, Cookie. These are thin wrappers whose methods mirror Puppeteer's JS API 1:1, so the official Puppeteer docs are your primary reference; the PHP wrapper just changes the syntax.nesk/rialto — the underlying IPC bridge (Symfony Process ↔ Node). This is where timeouts, debugging, and serialization behaviour live.First use case: a screenshot/PDF endpoint. It's the shortest path to a working bridge and exercises the parts you'll rely on later (navigation, viewport, waiting, file output). Swap in $page->pdf(['format' => 'A4', 'printBackground' => true]) for PDFs (headless only).
Treat the bridge as a synchronous RPC client. Every method call is a round trip to a Node process that blocks until the promise resolves. Write code accordingly.
Service provider registration (Laravel):
// AppServiceProvider
$this->app->singleton(Puppeteer::class, fn () => new Puppeteer([
'executable_path' => '/usr/bin/node',
// 'node_modules_path' => base_path('node_modules'),
]));
Keep the Puppeteer instance as a singleton, but never a singleton Browser. Launch a browser per job and close it in finally — long-lived browsers leak memory and zombie processes.
Queue job pattern:
class CapturePage implements ShouldQueue
{
public $timeout = 120;
public $tries = 3;
public function handle(Puppeteer $puppeteer): void
{
$browser = $puppeteer->launch([
'executablePath' => config('services.chrome.path'),
'args' => ['--no-sandbox', '--disable-dev-shm-usage'],
]);
try {
$page = $browser->newPage();
$page->setDefaultNavigationTimeout(60000);
$page->goto($this->url, ['waitUntil' => 'networkidle0']);
$page->waitForSelector('#app .ready', ['timeout' => 15000]);
$page->screenshot(['path' => $this->targetPath, 'fullPage' => true]);
} finally {
$browser->close();
}
}
}
Data extraction — always pass arguments, never string-interpolate:
$html = $page->evaluate('() => document.documentElement.outerHTML');
$rows = $page->evaluate(
'(selector) => [...document.querySelectorAll(selector)].map(el => ({
title: el.querySelector("h2")?.innerText,
href: el.querySelector("a")?.href,
}))',
['article.card']
); // PHP array back — anything JSON-serializable survives the bridge
evaluate() takes a JS function as a string plus an array of JSON-serializable arguments. You cannot pass PHP closures. When you need an element, don't round-trip the whole node — grab data in one evaluate call.
Element handles for interactions:
$input = $page->querySelector('input[name="email"]');
$input->click();
$input->type('[email protected]', ['delay' => 30]);
$btn = $page->querySelector('button[type="submit"]');
$btn->click();
Login / cookie reuse:
$page->authenticate(['username' => $u, 'password' => $p]);
$page->setExtraHTTPHeaders(['Accept-Language' => 'en-US,en;q=0.9']);
// persist between jobs
$cookies = $page->cookies();
// later
foreach ($cookies as $cookie) { $page->setCookie($cookie); }
Speed up blocking assets with request interception:
$page->setRequestInterception(true);
$page->on('request', function ($request) {
$skip = ['image', 'font', 'media'];
in_array($request->resourceType(), $skip, true)
? $request->abort()
: $request->continue();
});
Event listeners are plain PHP closures and are dispatched from Node synchronously:
$page->on('console', fn ($msg) => logger()->debug('browser: '.$msg->text()));
$page->on('pageerror', fn ($e) => logger()->error('page: '.$e->message()));
This package is archived. Pin the version in composer.json, and expect friction on PHP 8.x and modern symfony/process / nesk/rialto constraints. If Composer refuses, check the exact constraint before reaching for --ignore-platform-reqs. For new work, weigh this against Spatie's Browsershot (same Puppeteer engine, much smaller surface, actively maintained) or a plain HTTP client if you only need HTML.
Navigation waits are racy. In JS you'd do Promise.all([page.waitForNavigation(), page.click()]). The synchronous facade can't express that, so click() followed by waitForNavigation() may miss the event. Prefer waiting on content (waitForSelector, waitForFunction) or polling $page->url() after the click. Same trap applies to waitForNavigation on SPA route changes.
Chromium on servers: running as root requires --no-sandbox --disable-setuid-sandbox; containers need --disable-dev-shm-usage. Set executablePath (or PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 on npm install) to reuse a system Chromium instead of downloading one per deploy. Missing shared libs (libnss3, libatk, libgbm, etc.) surface as opaque "Failed to launch browser" errors — install Puppeteer's documented dependency list.
Errors come from the Node side. A null selector, a JS syntax error in your evaluate string, or a crashed page all arrive as Rialto/PuPHPeteer exceptions (e.g. InstructionException, Node\FatalException). Wrap calls in try/catch and log the message verbatim — it usually contains the JS stack you need.
Debugging the bridge. If calls hang, the IPC is the suspect, not your code. Raise read_timeout/idle_timeout on the Puppeteer constructor for slow pages (default read timeouts are aggressive for networkidle0 on heavy sites). Rialto's process class is the place to enable raw stderr passthrough — Node's stdout/stderr is otherwise swallowed.
Batching beats chit-chat. Ten evaluate() calls cost ten process round trips. One evaluate() returning a structured array is often 5–10× faster. Same for querySelectorAll + evaluate versus looping over handles in PHP.
Serialization boundaries. Only JSON-safe values cross: no PHP objects, resources, or closures in return values or arguments. Dates become strings, undefined becomes null, and huge DOM dumps are slow — prefer $page->content() over evaluate('() => document.documentElement.outerHTML') when you want full markup.
Always close the browser in finally, and guard against orphaned Chrome processes in long-running workers (--single-process is tempting but unstable). Add a health check or pkill sweep in your supervisor config if you run many jobs.
Extension points are shallow: subclass Nesk\Puphpeteer\Resources\Page and register it on the Puppeteer instance if you want helpers like waitForText() or clickAndWait() — that's the cleanest way to encode the navigation-wait workaround once and reuse it.
How can I help you explore Laravel packages today?