zoon / puphpeteer
A PHP client running Puppeteer in embedded QuickJS.
Requires
- php: ^8.4
- ext-json: *
- ext-php_quickjs: >=0.0.3
- amphp/amp: ^3.1
- amphp/byte-stream: ^2.1
- amphp/file: ^4.0
- amphp/http-client: ^5.3
- amphp/process: ^2.1
- amphp/websocket-client: ^2.0
- psr/log: ^3.0
- symfony/console: ^7.4|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- nikic/php-parser: ^5.6
- phpunit/phpunit: ^11
- vimeo/psalm: ^7.0.0-beta19
Suggests
- ext-pcntl: Recommended with ext-posix to terminate all processes of locally launched Chrome on Unix.
- ext-posix: Recommended with ext-pcntl to terminate all processes of locally launched Chrome on Unix.
- ext-uv: Recommended by amphp/file for asynchronous filesystem operations.
Provides
None
Conflicts
None
Replaces
None
- v3.x-dev
- v3.0.0-beta3
- v3.0.0-beta2
- v3.0.0-beta1
- v2.4.3
- v2.4.2
- v2.4.1
- v2.4.0
- v2.3.3
- v2.3.2
- v2.3.1
- v2.3.0
- v2.2.0
- 2.1.1
- 2.1.0
- 1.6.0
- 1.5.0
- 1.4.1
- 1.4.0
- 1.3.0
- 1.2.0
- 1.1.0
- 1.0.0
- 0.2.2
- 0.2.1
- 0.2.0
- 0.1.0
- dev-prototype-native
- dev-zoon
- dev-latest-dependencies
- dev-docs
- dev-puppeteer-10
- dev-dev
- dev-puppeteer-extra
- dev-master
This package is auto-updated.
Last update: 2026-10-19 18:09:10 UTC
README
English | Russian
A Puppeteer bridge for PHP. Original Puppeteer runs inside the PHP process through php-quickjs; Amp handles WebSocket transport, timers and PHP callbacks. Browser operations do not require a Node.js process.
Generated wrappers cover part of Puppeteer's API. npm plugins can be loaded from an application's node_modules within the documented compatibility limits.
Contents
- Requirements
- Installation
- Usage
- Use with browserless
- Notable differences from Puppeteer
- Puppeteer plugins
- Host APIs
- Runtime lifecycle
- IDE support
- Upgrade from v2
- Benchmark results
- Development
- License
- Logo attribution
Requirements
- PHP 8.4+ and Composer.
- The enabled php-quickjs extension fork >= 0.0.3. Build and installation instructions.
- Local Chrome or access to Browserless.
- For reliable cleanup of locally launched Chrome processes on Unix: the optional
pcntlandposixextensions. - To download local Chrome: PHP HTTPS streams (
allow_url_fopen=1, OpenSSL) and theunzipcommand.
Node.js and npm are not required to run the package or install a browser. Applications using npm plugins need npm to install packages; deployment must include node_modules, but no Node.js process runs at runtime. ESM-only packages may also need a build step.
Installation
Run from your application's root directory:
composer require zoon/puphpeteer
If you need local Chrome, install the compatible version:
php vendor/bin/console browser:install
The command downloads pinned Chrome into .chrome at the application root. Repeated runs reuse the installed browser. Add /.chrome/ to your application's .gitignore. Skip this step for Browserless or Chrome already provided by Dockerfile-chrome.
Composer does not run dependency scripts. For automatic installation, add hooks to your application's composer.json:
{
"scripts": {
"browser:install": "@php vendor/bin/console browser:install",
"post-install-cmd": "@browser:install",
"post-update-cmd": "@browser:install"
}
}
If hooks already exist, append the command to their arrays. Do not use --no-scripts for automatic execution; the explicit PHP command also works without hooks. npm approvals and Composer allow-plugins are not required.
For Browserless, set PUPPETEER_SKIP_DOWNLOAD=true. Dockerfile-chrome already sets this flag and PUPPETEER_EXECUTABLE_PATH. A nonempty PUPPETEER_EXECUTABLE_PATH also disables downloading. These ENV variables must be available during dependency installation too.
Browser path
The installer and launch() share .chrome at the application root or PUPPETEER_CACHE_DIR. Relative ENV paths are resolved against the application root. launch() never downloads Chrome or searches for a system browser.
| Setting | Effect |
|---|---|
launch(['executablePath' => '/path/to/chrome']) |
Explicit path; highest priority |
PUPPETEER_EXECUTABLE_PATH |
Browser path when no explicit option is supplied |
PUPPETEER_CACHE_DIR |
Directory for installing and locating locked Chrome |
PUPPETEER_SKIP_DOWNLOAD=true |
Skip browser download |
PUPPETEER_CHROME_SKIP_DOWNLOAD=true |
Skip Chrome; PUPPETEER_SKIP_CHROME_DOWNLOAD is also accepted |
Usage
Navigate to a page and save a screenshot:
require 'vendor/autoload.php'; use Nesk\Puphpeteer\Puppeteer\Puppeteer; $puppeteer = new Puppeteer(); $browser = $puppeteer->launch(); try { $page = $browser->newPage(); $page->goto('https://example.com'); $page->screenshot(['path' => 'example.png']); } finally { $browser->close(); }
Use with browserless
The following example runs from this repository after development setup:
docker compose up -d browserless docker compose run --rm php php examples/03_browserless.php docker compose down
Compose supplies BROWSER_WS with its token. Outside Compose, pass your endpoint to connect(['browserWSEndpoint' => $url]); use browserURL for an HTTP debugging endpoint. disconnect() leaves the browser running; close() closes it.
| Timeout | Scope |
|---|---|
Container ENV TIMEOUT=300000 |
Entire session: 5 minutes in Compose; Browserless default is 30 seconds |
Client protocolTimeout |
Individual CDP call, milliseconds |
goto(..., ['timeout' => 30000]) |
Navigation, milliseconds |
Client timeouts do not extend the server session. Browserless v1 used CONNECTION_TIMEOUT. Apply ENV changes with docker compose up -d browserless. Browserless reference.
Notable differences from Puppeteer
Only Chrome/Chromium over CDP is supported. Firefox/BiDi, pipe transport, Node.js writable streams, the old screencast() API and followSymlinks: false are unsupported.
Browser API classes live in Nesk\Puphpeteer\Puppeteer (for example, Puppeteer, Page, Browser). Shared JsFunction remains in Nesk\Puphpeteer; compatibility aliases for old resource imports are described under Upgrade from v2.
Instantiate Puppeteer
Use new Puppeteer() in place of JavaScript's Puppeteer import. launch() starts Chrome from PHP; connect() attaches to an existing browser. The original Puppeteer logic executes in embedded QuickJS.
Promises are awaited automatically
You do not need to call Revolt\EventLoop::run() explicitly: public methods await internal Futures, which let the event loop run while waiting. Concurrency is cooperative: sleep() and long synchronous PHP operations block other tasks; use Amp\delay() and asynchronous I/O instead.
Public methods return their result or throw an exception directly. Properties use normal PHP syntax, for example $page->keyboard->press('Enter'). Futures remain internal to the transport. Run independent operations concurrently with Amp:
$results = Amp\Future\await([ Amp\async(fn() => $firstPage->title()), Amp\async(fn() => $secondPage->title()), ]);
Some methods have PHP aliases
PHP cannot represent Puppeteer's $ method names:
| Puppeteer | PHP |
|---|---|
$ |
querySelector |
$$ |
querySelectorAll |
$eval |
querySelectorEval |
$$eval |
querySelectorAllEval |
$divs = $page->querySelectorAll('div'); $headings = $page->querySelectorAll('::-p-xpath(//h2)');
Static methods and properties
Call JS static methods through an existing PHP object. Without an instance, getStaticClass() returns a typed constructor wrapper in the same QuickJS runtime:
use Nesk\Puphpeteer\Puppeteer\Locator; $names = $puppeteer->customQueryHandlerNames(); $page = $browser->newPage(); $page->getStaticClass(Locator::class) ->race([$page->locator('#accept'), $page->locator('#continue')]) ->click(); // With an existing Locator: $locator->race([$locator, $other])->click();
The getter is also available on $puppeteer: before launch()/connect() it creates the next connection's runtime; afterwards it uses the latest connection. Do not mix objects from different connections. Use $browser or $page to select a specific browser's runtime.
Read and assign properties directly ($object->property = $value) when upstream permits writing. IDEs see typed PHP 8.4 property hooks. Compatible overloads are merged; Symbol methods and Symbol events remain unsupported.
JavaScript functions use JsFunction
Pass a complete function source, or build it using the compatible factories:
$pageFunction = new JsFunction('(element) => element.textContent'); $pageFunction = JsFunction::createWithParameters(['element']) ->body('return element.textContent;');
JsFunction executes in JavaScript: in the browser for evaluate(), in QuickJS for Puppeteer events. PHP Closure callbacks execute in PHP. Event methods on(), once() and off() preserve the identity of the same handler object; PHP handlers can call client methods and suspend through Amp.
Catch exceptions directly
No ->tryCatch modifier is required:
try { $page->goto('invalid_url'); } catch (\RuntimeException $exception) { // Handle the error; a JavaScript error does not by itself close the client. }
Reading streams
createPDFStream() returns an Amp\ByteStream\ReadableStream. QuickJS reads the CDP stream in chunks up to 64 KiB; PHP decodes binary payloads and yields to the event loop between reads. No complete-file buffer is added by the bridge. The source may have its own buffer; synchronous JS production and each chunk transfer still take CPU time.
$stream = $page->createPDFStream(); try { while (($chunk = $stream->read()) !== null) { // Write to an asynchronous file or HTTP output stream. } } finally { $stream->close(); }
Amp pipe() and buffer() work with this stream (buffer() intentionally collects everything). read($cancellation) cancels and closes only that stream. Early close also releases the PDF's CDP handle. Node.js writable streams are a separate interface and are not provided by this adapter.
Recording video
$page->record() uses Chrome's experimental Page.startScreenRecording API and
returns an Amp readable stream. See
05_video_stream.php for live MP4 output with
pipe().
Limitations: the browser must support this experimental CDP method; output is
MP4 with no codec or container selection; the first fragment may be delayed;
stop() is required to finalize the stream. With path, the path belongs to the
PHP application's filesystem, including when Chrome runs through Browserless.
PHP opens the file with Amp and continuously pipes the CDP stream to disk, applying
backpressure while writes complete. File recordings return an empty stream once
stop() finishes. Read the completed file if you need its bytes. Omit path
to receive live video through the readable stream.
Puppeteer plugins
See the runnable stealth + fingerprint example for both plugins in one PHP flow. Module files and compressed assets are served by read-only PHP callbacks through php-quickjs's direct bridge; package versions remain under the application's control.
Install plugin packages in your application:
npm install puppeteer-extra-plugin-stealth fingerprint-injector
use Nesk\Puphpeteer\JsRuntime; use Nesk\Puphpeteer\Puppeteer\Puppeteer; $js = new JsRuntime(moduleRoot: __DIR__); $stealth = $js->require('puppeteer-extra-plugin-stealth'); ['newInjectedPage' => $newInjectedPage] = $js->require('fingerprint-injector'); $puppeteer = new Puppeteer(runtime: $js); $puppeteer->use($stealth()); $browser = $puppeteer->launch(); try { $page = $newInjectedPage($browser, [ 'fingerprintOptions' => ['devices' => ['mobile'], 'operatingSystems' => ['ios']], ]); $page->goto('https://example.com'); } finally { $browser->close(); }
Keep node_modules and the packages' non-JS data files in deployment. Pin package versions with package-lock.json. Upgrading a plugin does not require rebuilding PuPHPeteer. If another package needs ESM or unsupported syntax, build it to CommonJS with standard tooling and pass the resulting file path to require().
For custom JavaScript logic, use $js->run(new JsFunction('async (...) => { ... }'), ...$arguments); it runs in the same QuickJS context as the loaded packages. $page->evaluate() instead runs inside the web page.
use() returns the same Puppeteer instance. Register plugins before its single
launch() or connect() call. Missing dependencies and incompatible requirements
fail before launching Chrome. Exported plugin instances belong to their JsRuntime;
keep that runtime alive while using the browser. Launch preparation respects
timeout (default 30 seconds); connect preparation uses protocolTimeout or
read_timeout (default 180 seconds). Zero disables that preparation deadline.
To select stealth evasions, pass their upstream names when creating the plugin:
$puppeteer->use($stealth(['enabledEvasions' => [ 'navigator.webdriver', 'navigator.languages', 'navigator.hardwareConcurrency', ]]));
Individual evasions can be loaded by their npm package path with require() when needed.
The loader supports CommonJS modules, not arbitrary ESM or native Node addons. Its Node compatibility layer offers only selected builtins and read-only filesystem access inside the supplied module root. Put plugins in a dedicated directory without secrets: npm code can read files anywhere inside that root. This is compatibility, not a security sandbox; install only trusted packages. It does not start Node.js or provide a general-purpose Node process or Node network stack. A package requiring an unsupported API will fail with an error; compatibility with every npm package is not guaranteed.
Compatibility limits
-
Page patches take effect when a document loads.
newPage()waits for the plugin'sonPageCreated()handler, so a followinggoto()loads the document with its registered patches. Connecting to an already open page does not change scripts that have already run. For example, if a page readnavigator.webdriverbefore you connected with stealth, that earlier result remains unchanged. Reload the page to apply scripts registered withevaluateOnNewDocument()to a new document. -
The first popup document may load before the plugin is ready. A site's
window.open('/check')can run/checkscripts before stealth finishes preparing the popup. If those scripts need the patches from the start, obtain the popup page and then navigate it to/checkagain. This affects the new load; it does not undo checks or requests from the first load. -
Language overrides apply to the browser connection, not saved profile settings. For example, the stealth
user-agent-overrideevasion with localede-DE,deconfigures the language through CDP in both headless and visible Chrome. It does not write that language to the profile'sPreferencesfile: relaunching the same profile without the plugin does not preserve this setting. The order of HTTP headers may also differ from puppeteer-extra running in Node.js. -
A plugin error becomes a PHP exception, sometimes on the next call. For example, if
onPageCreated()fails while you call$browser->newPage(), that call throws. If a hook fails in the background when a popup appears, the next client operation can throw even though it did not create the popup. Keep browser cleanup infinally:close()anddisconnect()remain available after plugin failures. A target disappearing during initialization because its page closed is treated as normal cleanup. -
Custom plugins must await their asynchronous work. For example, write
async onPageCreated(page) { await page.evaluateOnNewDocument(patch); }. If the handler starts that operation but neither awaits nor returns its Promise,newPage()can return before the patch is registered, and the adapter cannot reliably report its later failure. -
Stealth does not guarantee that a website will accept the browser as human. For example, hiding
navigator.webdriverdoes not prevent a site from showing a CAPTCHA based on the IP address or repeated requests. Our tests check particular browser properties and plugin execution; they do not certify that automation is undetectable.
Host APIs
APIs run in QuickJS (JsRuntime::run() and npm modules). Page code uses Chrome APIs. Native implementations are preserved.
- Basics:
URL,URLSearchParams,structuredClone,DOMException,setImmediate,clearImmediate,ReadableStream. - UTF-8:
TextEncoder,TextDecoderwith{stream: true}and a finaldecode()flush. - Cancellation:
AbortController,AbortSignal,reason,throwIfAborted(),abort(),timeout(),any(); fetch also cancels PHP I/O. - Crypto via PHP:
getRandomValues()for integer typed arrays (up to 65,536 bytes),randomUUID()andsubtle.digest()for SHA-1/256/384/512. Othersubtlemethods reject withNotSupportedError. - HTTP via Amp:
fetch,Headers,Request,Response; streamed responses in chunks up to 64 KiB,text(),json(),arrayBuffer(),bodyUsed,clone()and redirectsfollow/manual/error.text()uses the streaming UTF-8 decoder. HTTP error statuses return Response objects; network errors reject. Client shutdown releases requests.
$runtime = new \Nesk\Puphpeteer\JsRuntime(); $result = $runtime->run(new \Nesk\Puphpeteer\JsFunction(<<<'JS' async url => { const response = await fetch(url, {signal: AbortSignal.timeout(5000)}); return {status: response.status, text: await response.text(), id: crypto.randomUUID()}; } JS), 'https://example.com/');
Host fetch does not inherit Chrome cookies, proxies or authentication; there is no cookie jar, CORS enforcement or HTTP cache. Manual redirects expose their status and headers. Request bodies support strings, URLSearchParams and BufferSource; streaming uploads and Blob/FormData are unsupported. text()/json()/arrayBuffer() buffer the result; response.body remains streamed, while clone() may buffer a slower branch.
Runtime lifecycle
QuickJS diagnostics use PSR-3. By default, PuPHPeteer writes them asynchronously to stderr. Pass any PSR-3 logger, including Monolog, as the second constructor argument:
$puppeteer = new Puppeteer(logger: $logger);
JavaScript console.debug, console.log/console.info, console.warn and console.error map to the PSR-3 debug, info, warning and error levels.
- Close pages/contexts with
close()and JS handles withdispose(), preferably infinally.release()only drops the bridge reference. Do not pass objects between clients. on(),once()andoff()preserve handler identity. Removing the last registration releases the callback; other callbacks, such asexposeFunction(), remain until disconnect.- Event callback errors are sent to the logger; callbacks returning a result reject their JS Promise on failure.
- Ordinary JS errors and operation timeouts leave the connection usable. Transport failure clears calls, timers and registries; create a new connection afterward.
undefinedbecomesnull, binary becomes a PHP string, empty JS objects and arrays both become[]. Exact integers must fit ±9,007,199,254,740,991. BigInt/non-finite results use tagged arrays; cyclic or excessively deep data is rejected.
PHP wrappers do not expose Puppeteer's AbortSignal parameters; use Host APIs for cancellation inside QuickJS.
IDE support
The package includes PHP classes, methods, property getters, signatures and PHPDoc generated from the pinned Puppeteer declarations. PhpStorm completion works after Composer installation, without additional commands. Psalm checks these types; JS Promise return types become their resolved PHP types. API coverage is partial; see the generator contract for type limitations.
Upgrade from v2
Install PHP 8.4+, the compatible QuickJS extension and Chrome as described above. For npm plugins, install their packages as described in Puppeteer plugins.
Composer loads best-effort aliases only for Nesk\Puphpeteer\Resources\…. Update the Puppeteer and JsFunction imports; for new code use:
| v2 import | Current import |
|---|---|
Nesk\Puphpeteer\Puppeteer |
Nesk\Puphpeteer\Puppeteer\Puppeteer |
Nesk\Puphpeteer\Resources\Page (and other wrappers) |
Nesk\Puphpeteer\Puppeteer\Page |
Nesk\Rialto\Data\JsFunction |
Nesk\Puphpeteer\JsFunction |
Aliases preserve instanceof for available wrappers, but do not restore removed upstream methods or replace classes already loaded by the application. querySelector*() names and automatic waiting remain unchanged.
Errors: remove tryCatch and catch RuntimeException instead of Rialto exceptions. An ordinary Puppeteer operation error does not close the client.
// v2 try { $page->tryCatch->goto('invalid_url'); } catch (\Nesk\Rialto\Exceptions\Node\Exception $error) { echo $error->getMessage(); } // v3 try { $page->goto('invalid_url'); } catch (\RuntimeException $error) { echo $error->getMessage(); }
Plugins: replace JavaScript initialization in js_extra with JsRuntime::require() (see Puppeteer plugins).
// v2 $puppeteer = new Puppeteer(['js_extra' => " const puppeteer = require('puppeteer-extra'); puppeteer.use(require('puppeteer-extra-plugin-stealth')()); instruction.setDefaultResource(puppeteer); "]); // v3 $js = new JsRuntime(moduleRoot: __DIR__); $puppeteer = new Puppeteer(runtime: $js); $stealth = $js->require('puppeteer-extra-plugin-stealth'); $puppeteer->use($stealth());
Timeouts and HTTPS: the old options still map automatically; prefer the upstream names. protocolTimeout is in milliseconds, whereas read_timeout was in seconds. Navigation has its own timeout.
// v2 $puppeteer = new Puppeteer(['read_timeout' => 65]); $browser = $puppeteer->launch(['ignoreHTTPSErrors' => true]); // v3 $puppeteer = new Puppeteer(); $browser = $puppeteer->launch([ 'protocolTimeout' => 65000, 'acceptInsecureCerts' => true, ]); // Both versions: $browser->newPage()->goto($url, ['timeout' => 60000]);
JavaScript functions: existing factories still work. Replace a direct (parameters, body, scope) constructor with a factory or a complete function source:
// v2 $function = new JsFunction(['element'], 'return element.textContent;', []); // v3: factory (also compatible with v2) $function = JsFunction::createWithParameters(['element']) ->body('return element.textContent;'); // Or v3 source syntax: $function = new JsFunction('(element) => element.textContent');
Other behavior changes:
- Node options, the v2 logger option and
js_extrathrow an exception. Inject a PSR-3 logger asnew Puppeteer(logger: $logger). Local launch uses installed Chrome,executablePathorPUPPETEER_EXECUTABLE_PATH; system Chrome is not selected automatically. - Function scope/defaults accept scalars, arrays and
JsFunction; pass remote handles as separateevaluate()arguments. PHP callbacks must beClosureobjects. UseAmp\async()for concurrency; public results need no manualawait(). undefinedbecomesnull; binary results are PHP strings. Screenshot, PDF and script/style filesystem operations run on the PHP host. A base64 screenshot returns without writingpath.
Benchmark results
The reference QuickJS runs were measured on macOS arm64 with PHP 8.5.7 and managed Chrome 144.0.7559.96. Values below are means; benchmark execution is documented in performance measurements.
- Rialto — PHP controls a separate Node.js process over a socket; Puppeteer runs in Node.js.
- Native PHP — a PHP implementation speaks CDP directly, without Node.js or QuickJS; API coverage is partial.
- QuickJS — the Puppeteer bundle runs inside an embedded QuickJS runtime in PHP; Amp handles CDP WebSocket I/O.
| Measurement | Rialto | Native PHP | QuickJS optimized |
|---|---|---|---|
1,000 evaluate calls |
413.02 ms | 331.68 ms | 354.34 ms |
| 100 returns of 64 KiB | 114.60 ms | 103.78 ms | 76.92 ms |
| 20 waits of 25 ms | 545.30 ms | 29.64 ms | 29.86 ms |
| Total client CPU | 830 ms | 360 ms | 428 ms |
| Peak client RSS | 123.55 MiB | 38.14 MiB | 56.53 MiB |
Development
Commands in this section maintain the package and run from a repository checkout. API generation, builds and JS tests require Node.js 22+ and npm, which are included in the Docker images.
Development Docker environment
Requires Docker and Compose 2.17+. The image builds the pinned fork SHA and enables it through PHP ini; no host PHP/Rust toolchain is needed.
docker compose build php chrome docker compose run --rm php composer install docker compose run --rm php npm ci docker compose run --rm chrome php examples/01_page_open.php
Dockerfile provides PHP, QuickJS, Composer and Node.js without a browser. Dockerfile-chrome installs Chrome from upstream/lock.json into /opt/chrome using the same PHP installer and sets PUPPETEER_EXECUTABLE_PATH=/usr/local/bin/chrome. Neither image contains application code or project dependencies, and neither starts a command automatically. Compose mounts the entire checkout at /app, including vendor and node_modules; dependency installation writes directly to the host directory. npm ci installs build/test tooling and the npm plugins used by the examples. When switching between macOS and Linux, rerun npm ci in the target environment: native npm binaries depend on the OS as well as the CPU architecture. Rebuild after Dockerfile, extension or locked Chrome changes.
Images use the host architecture; Chrome availability depends on the locked version. The Chrome service uses SYS_ADMIN for its sandbox. The image has no graphical display.
Updating Puppeteer and generating the API
When updating Puppeteer, install the new dependency, regenerate the PHP API, build the JS bundle and rebuild the Chrome image:
docker compose run --rm php npm install --save-dev --save-exact puppeteer-core@latest
docker compose run --rm php composer update-php
docker compose run --rm php composer build
docker compose build chrome
docker compose run --rm chrome composer test
composer update-php updates PHP wrappers, compatibility aliases, README badges and upstream/lock.json. Classes, methods and aliases removed from upstream disappear from the output; manual edits to generated files are overwritten. Use composer update-php -- --verbose to show skipped declarations and their reasons. Parsing and TypeScript errors stop generation before files are written.
composer verify-php checks the result without changing files. API generation and bundle building are separate steps: run composer build after changing JS sources or dependencies; use php bin/console build --debug for a readable bundle.
Commit source changes, generated files, resources/, upstream/ and changes to package.json/package-lock.json together. The lock file pins the package's JS dependencies; applications pin their own plugins separately. This package does not commit composer.lock. To update PHP dependencies separately, use docker compose run --rm php composer update.
Tests and coding style
Run the full change validation:
docker compose run --rm chrome composer test
It includes PHP unit/integration/browser tests, JS tests, Psalm, PHP style, generated API and JS bundle checks. Load checks and benchmarks are documented under release validation.
For a single PHP suite, use php bin/console test unit (or integration / browser); for individual tests, use php vendor/bin/phpunit --filter=.... These commands also run through docker compose run --rm chrome.
PHP style follows Symfony (PHP CS Fixer), with spaces around . and class imports, including built-in classes. composer cs:check checks style; composer cs:fix applies it. Generated PHP follows the same rules.
Without QuickJS, PHP unit tests and Psalm are available:
PUPPETEER_SKIP_DOWNLOAD=true composer install --ignore-platform-req=ext-php_quickjs php vendor/bin/phpunit composer psalm
CLI help: docker compose run --rm php php bin/console. doctor checks the environment; --no-interaction disables prompts, and --json selects machine-readable output.
Examples
Run docker compose run --rm chrome php examples/<filename>.
| File | Demonstrates |
|---|---|
| 01_page_open.php | Launch options, viewport, timeout and evaluate |
| 02_page_screenshot.php | Stealth, User-Agent, language, viewport scale and screenshot |
| 04_form_intercept.php | Wait for POST before clicking, print data and abort the request |
| 05_video_stream.php | Stream a live MP4 recording to a file with Amp pipe() |
| 06_stealth_fingerprint.php | Stealth and a mobile fingerprint through npm packages |
Local HTML needs no HTTP server; screenshots persist on the host. For a visible browser on a host with PHP/QuickJS and a graphical display: php examples/01_page_open.php --headful (headless => false).
Extension contract
Use the fork SHA pinned in Dockerfile. The bridge requires automatic Promise awaiting through Revolt, quickjs.postMessage() and QuickJS::drainMessages(). Integration tests check values, limits, recovery, resource release and Fibers against the installed extension.
The canonical native API stubs belong to php-quickjs. stubs/php_quickjs.php is a copy for Psalm; integration tests compare its signatures with the loaded extension. Client executes the ready JavaScript bundle with eval($source, typescript: false), bypassing Oxc and its transpile cache. For older supported extensions with a one-argument eval(), it selects the previous call by inspecting the method signature.
One asynchronous JS consumer waits for native notifications while Amp handles host I/O. Messages are copied through the native queue (32 MiB by default). PuPHPeteer limits the JS heap to 256 MiB and stack to 512 KiB. Puppeteer and Amp control operation timeouts; PuPHPeteer no longer sets a separate native execution timeout. Bridge values are limited to a conversion depth of 64; the separate TypeScript transpile cache defaults to at most 256 entries and 32 MiB of source, JavaScript and source-map strings. This cache budget does not cover all Oxc allocations. These limits do not bound PHP callbacks or Chrome memory.
The client closes the transport on a bridge failure without retrying; JS mutations are not rolled back. PHP callbacks run outside the native JS stack so they can suspend their Fiber.
Release validation
GitHub Actions on push, PR and manual dispatch checks PHPUnit/Psalm on PHP 8.4/8.5, JS, bundles, native integration, browser scenarios and examples. Composer and images are cached; project tests run on cache hits too.
Run load tests and benchmarks only for releases:
docker compose run --rm chrome composer test-release -- --cycles=50 --timeout=600 docker compose run --rm chrome composer benchmark -- --trials=5 --iterations=1000
Each load cycle checks two pages, listeners, handle release and error recovery; every tenth renders PNG/PDF. Resource registries must return to baseline. --timeout bounds the workload in seconds; increase both arguments for longer runs. These checks do not prove absence of all native leaks.
The release workflow runs after publication, including prereleases, and does not block it. Check dependency audits and target platforms before publishing: hosted runtime CI covers Linux amd64.
Repeatable performance measurements
Each trial measures 1,000 evaluates, 100 × 64 KiB returns, 20 concurrent 25 ms waits and 20 navigations. --iterations changes evaluate count. CPU/RSS exclude Chrome; 25 ms sampling may miss peaks. Compare on identical hardware, PHP, Chrome, extension and bundle. macOS/Linux require ps and /usr/bin/time (GNU time on Linux).
License
The MIT License (MIT). See the License File.
Logo attribution
PuPHPeteer's logo is composed of:
- Puppet by Luis Prado from the Noun Project.
- Elephant by Lluisa Iborra from the Noun Project.
Thanks to Laravel News for picking the icons and colors of the logo.
