chrome-php/chrome
Chrome PHP lets you control headless Chrome or Chromium from PHP, synchronously or asynchronously. Open pages, navigate, run JavaScript, take screenshots, generate PDFs, and emulate mouse and keyboard input with a simple, IDE-friendly API. Requires PHP 7.4–8.5 and Chrome/Chromium 65+.
Three steps to first success:
Install the package and a Chrome binary. The package is a DevTools-protocol client, not a browser.
composer require chrome-php/chrome
# one of:
apt-get install -y chromium-browser # Debian/Ubuntu
# or run inside a chrome-enabled container image
Point the factory at it. Either set CHROME_PATH in .env, or inject the binary explicitly:
$factory = new \HeadlessChromium\BrowserFactory(env('CHROME_PATH', 'chromium-browser'));
In Docker you will almost certainly also need noSandbox => true (see Gotchas).
Run the smallest useful thing — usually a PDF, since that is the #1 day-to-day reason a Laravel app pulls this in:
$browser = (new BrowserFactory())->createBrowser(['noSandbox' => true, 'windowSize' => [1280, 800]]);
try {
$page = $browser->createPage();
$page->setHtml(view('invoices.pdf', $invoice)->render());
$page->pdf(['printBackground' => true])->saveToFile(storage_path("app/invoices/{$invoice->id}.pdf"));
} finally {
$browser->close();
}
Where to look first: in the README, the Available options table (that is your config surface), the Page API section (navigation, evaluate, pdf, screenshot), and the Page::* event constants for waitForNavigation. Then read BrowserFactory, Browser, and Page classes — the API is thin and IDE-friendly, so the source is often better documentation than the README.
First real use case: server-side PDF rendering of Blade views (invoices, reports, tickets) where you need CSS/JS fidelity that Dompdf and Snappy cannot give you. Screenshot capture and JS-heavy scraping are the next two.
Bind a configured factory in a service provider. Keep options out of call sites and read them from config/services.php:
// AppServiceProvider::register()
$this->app->bind(BrowserFactory::class, fn () => tap(
new BrowserFactory(config('chrome.binary')),
fn ($f) => $f->setOptions(config('chrome.options'))
));
Use setOptions() / addOptions() when options should persist across every createBrowser() call; use the argument form when they should apply to a single browser only.
Run browser work in queued jobs, not in the request cycle. A cold Chrome start is 300–800 ms; heavy pages are seconds. Wrap the work in a job with $timeout, $tries, and always close() in finally:
public function handle(BrowserFactory $factory): void
{
$browser = $factory->createBrowser();
try {
$page = $browser->createPage();
$page->navigate($this->url)->waitForNavigation(Page::NETWORK_IDLE, 20000);
$page->screenshot()->saveToFile($this->targetPath());
} finally {
$browser->close();
}
}
Reuse one browser for multiple pages in a job. $browser->createPage() is cheap relative to browser startup. When rendering many invoices, create the browser once, loop pages, and $page->close() each one:
foreach ($invoices as $invoice) {
$page = $browser->createPage();
try {
$page->setHtml(view('invoices.pdf', compact('invoice'))->render());
$page->pdf(['printBackground' => true])->saveToFile($path);
} finally {
$page->close();
}
}
Prefer NETWORK_IDLE for any page that loads data via XHR. LOAD fires before client-side rendered content is in the DOM; NETWORK_IDLE waits for a quiet network. Use DOM_CONTENT_LOADED when you only need markup, and Page::LOAD (default) otherwise.
Use evaluate() for scraping, callFunction() for structured extraction. callFunction passes arguments cleanly and returns serializable values, avoiding string-escaping bugs in interpolated JS:
$rows = $page->callFunction(
'function () { return [...document.querySelectorAll("table tr")].map(r => r.innerText); }',
[]
)->getReturnValue();
Inject assets with addScriptTag / addPreScript. addPreScript runs before every navigation on that page — useful for stubbing navigator, intercepting fetch, or disabling analytics. setPagePreScript on the browser applies to every page it creates.
For a shared, long-lived browser (e.g. a worker process or several scripts on one box), use keepAlive => true, persist $browser->getSocketUri() to disk, and reconnect with BrowserFactory::connectToBrowser() inside a try/catch for BrowserConnectionFailed. This is not a pool — treat a persistent browser as single-tenant; do not let concurrent queue workers share one unless you isolate work by page and accept the blast radius of a crash.
Set userAgent, headers, proxyServer, ignoreCertificateErrors, and envVariables at the factory level rather than fighting the page API. These are browser-wide and consistently applied.
Testing: wrap the factory in a fake for unit tests, and reserve real browser calls for a small set of feature tests. Assert on the produced PDF/screenshot file size and mime type, not on byte equality.
noSandbox => true. The default sandbox needs user namespaces. Also add --disable-dev-shm-usage via customFlags if /dev/shm is small, otherwise Chrome crashes under load.BrowserFactory throws if it cannot find the executable. Pin CHROME_PATH explicitly in .env and config; do not rely on OS guessing.keepAlive => true leaks Chrome processes if your script exits without calling close(). Either clean up explicitly, or schedule a reaper. Zombie Chromium eats RAM fast.setHtml() replaces the document, it does not append. It also has its own timeout/event defaults (3000 ms / LOAD) that are separate from waitForNavigation.waitForNavigation() defaults to a 30 s timeout on Page::LOAD. Always catch OperationTimedOut and NavigationExpired — the latter fires when a redirect replaces the document you were waiting on, which is common on auth flows and SPAs.evaluate()->getReturnValue() is the blocking point, not evaluate(). If you navigate or click and then forget waitForPageReload(), you will read the old document.null or throw. Map to primitives in the JS.windowSize is the browser window; $page->setViewport(1280, 800) controls the rendering surface used for screenshots and layout. For deterministic PDFs, set the viewport and use pdf(['printBackground' => true]) — backgrounds are off by default and the omission is a classic "why is my invoice blank" bug.proxyServer does not accept credentials). Plan around it or put an authenticating proxy in front.--enable-automation flag trips some sites' bot detection. Remove it via excludedSwitches => ['--enable-automation'] together with a realistic userAgent.headless => false (needs a display on Linux), connectionDelay => 0.5, and debugLogger => 'php://stdout' or a PSR-3 logger. debugLogger accepts a stream path, a resource, or a LoggerInterface; wire it to Log::channel('chrome') in development only.waitForNavigation(…, 20000) inside a job with a 15 s $timeout will be killed mid-flight, orphaning the Chrome process.createBrowser() completely replace the factory defaults for that call; they are not merged.addPreScript / setPagePreScript for page hooks, a custom LoggerInterface for observability, and customFlags for anything Chrome does that the package does not expose. If you find yourself needing more, you are probably writing raw DevTools protocol — consider whether chrome-php/chrome is still the right layer.How can I help you explore Laravel packages today?