Search by

zoon / puphpeteer

zoon

A PHP client running Puppeteer in embedded QuickJS.

Package info

github.com/zoonru/puphpeteer

pkg:composer/zoon/puphpeteer

Statistics

Installs: 331 784

Dependents: 1

Suggesters: 0

Stars: 210

Open Issues: 7

v3.0.0-beta3 2026-10-19 18:06 UTC

README

Puppeteer Chrome

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

  • 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 pcntl and posix extensions.
  • To download local Chrome: PHP HTTPS streams (allow_url_fopen=1, OpenSSL) and the unzip command.

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

  1. Page patches take effect when a document loads. newPage() waits for the plugin's onPageCreated() handler, so a following goto() 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 read navigator.webdriver before you connected with stealth, that earlier result remains unchanged. Reload the page to apply scripts registered with evaluateOnNewDocument() to a new document.

  2. The first popup document may load before the plugin is ready. A site's window.open('/check') can run /check scripts before stealth finishes preparing the popup. If those scripts need the patches from the start, obtain the popup page and then navigate it to /check again. This affects the new load; it does not undo checks or requests from the first load.

  3. Language overrides apply to the browser connection, not saved profile settings. For example, the stealth user-agent-override evasion with locale de-DE,de configures the language through CDP in both headless and visible Chrome. It does not write that language to the profile's Preferences file: 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.

  4. 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 in finally: close() and disconnect() remain available after plugin failures. A target disappearing during initialization because its page closed is treated as normal cleanup.

  5. 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.

  6. Stealth does not guarantee that a website will accept the browser as human. For example, hiding navigator.webdriver does 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, TextDecoder with {stream: true} and a final decode() 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() and subtle.digest() for SHA-1/256/384/512. Other subtle methods reject with NotSupportedError.
  • HTTP via Amp: fetch, Headers, Request, Response; streamed responses in chunks up to 64 KiB, text(), json(), arrayBuffer(), bodyUsed, clone() and redirects follow/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 with dispose(), preferably in finally. release() only drops the bridge reference. Do not pass objects between clients.
  • on(), once() and off() preserve handler identity. Removing the last registration releases the callback; other callbacks, such as exposeFunction(), 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.
  • undefined becomes null, 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_extra throw an exception. Inject a PSR-3 logger as new Puppeteer(logger: $logger). Local launch uses installed Chrome, executablePath or PUPPETEER_EXECUTABLE_PATH; system Chrome is not selected automatically.
  • Function scope/defaults accept scalars, arrays and JsFunction; pass remote handles as separate evaluate() arguments. PHP callbacks must be Closure objects. Use Amp\async() for concurrency; public results need no manual await().
  • undefined becomes null; binary results are PHP strings. Screenshot, PDF and script/style filesystem operations run on the PHP host. A base64 screenshot returns without writing path.

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:

Thanks to Laravel News for picking the icons and colors of the logo.