jonnyw/php-phantomjs
PHP PhantomJS is a headless browser wrapper for PHP that lets you load pages, run JavaScript, capture screenshots, and export to PDF. It provides a clean, extensible API over the PhantomJS binary, supports custom scripts, and works with any PHP project.
1. Install the library
composer require jonnyw/php-phantomjs
2. Install the PhantomJS binary separately. The package is only a wrapper — it shells out to a phantomjs executable that must exist on the machine.
# Ubuntu/Debian (if the package still resolves)
apt-get install phantomjs
# or grab the 2.1.1 static build
wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2
tar -xjf phantomjs-2.1.1-linux-x86_64.tar.bz2 -C /opt
ln -s /opt/phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs
phantomjs --version # sanity check
3. Smoke-test before touching Laravel.
use JonnyW\PhantomJs\Client;
$client = Client::getInstance();
$client->getEngine()->setPath('/usr/local/bin/phantomjs'); // binary path
$client->getEngine()->setCacheDir(storage_path('app/phantomjs')); // must be writable
$request = $client->getRequest();
$request->setUrl('https://example.com');
$request->setTimeout(5000); // ms — hard process timeout
$request->setDelay(1000); // ms — extra wait after load
$response = $client->send($request);
if ($response->getStatus() !== 200) {
throw new RuntimeException('Phantom failed: '.$response->getStatus());
}
echo $response->getContent();
Where to look first (in this order):
| File | Why |
|---|---|
src/Client.php |
Entry point; getRequest(), send(), lazy mode, debug |
src/Engine.php |
Binary path, cache dir, CLI options, env vars |
src/Http/Request.php, PdfRequest.php, CaptureRequest.php |
The three request flavours |
src/Http/Response.php |
getContent(), getStatus(), getConsole(), getHeaders() |
src/Resources/procedures/*.proc |
The actual PhantomJS scripts — read these to understand what the wrapper does |
First real use case: generating a PDF of an existing Blade-rendered invoice route. It's the lowest-risk, highest-value application and exercises 90% of the package.
use JonnyW\PhantomJs\Http\PdfRequest;
$request = new PdfRequest();
$request->setUrl(route('invoices.show', $invoice));
$request->setFormat('A4');
$request->setOrientation('portrait');
$request->setMargin('10px');
$request->setTimeout(10000);
$request->setDelay(2000);
$response = $client->send($request);
Storage::put("invoices/{$invoice->id}.pdf", $response->getContent());
Client::getInstance() in app code)getInstance() is a static singleton — it makes mocking impossible and leaks state across requests in Octane/queue workers. Bind a configured instance instead.
// config/phantomjs.php
return [
'binary' => env('PHANTOMJS_BIN', '/usr/local/bin/phantomjs'),
'cache_dir' => storage_path('app/phantomjs'),
'timeout' => 10000,
'delay' => 1500,
'options' => [
'--load-images=false',
'--ignore-ssl-errors=true',
'--ssl-protocol=any',
],
];
// AppServiceProvider::register()
$this->app->singleton(\JonnyW\PhantomJs\Client::class, function () {
$client = \JonnyW\PhantomJs\Client::getInstance();
$client->getEngine()->setPath(config('phantomjs.binary'));
$client->getEngine()->setCacheDir(config('phantomjs.cache_dir'));
foreach (config('phantomjs.options') as $option) {
$client->getEngine()->addOption($option);
}
return $client;
});
Wrap it in your own PdfRenderer / PageScraper class so the rest of your app never imports JonnyW\* directly. That's what makes the eventual migration to Browsershot/Chrome cheap.
PhantomJS has no access to your APP_URL unless you tell it. For a PDF of a local page, either bake absolute URLs into the Blade template (asset(), url()) or force the root URL:
URL::forceRootUrl(config('app.url'));
Also send an auth cookie/header if the route is protected — the request carries no session:
$request->addHeader('Cookie', 'session='.encrypt($sessionId));
$request->setMethod('POST');
$request->setUrl('https://api.example.com/search');
$request->setHeaders(['Accept' => 'application/json']);
$request->setContentType('application/json');
$request->setBody(json_encode(['q' => 'laravel']));
PhantomJS renders the page; getContent() gives you the post-JS DOM, getPlainText() gives you stripped text.
use JonnyW\PhantomJs\Http\CaptureRequest;
$request = new CaptureRequest();
$request->setUrl($url);
$request->setWidth(1280);
$request->setHeight(800);
$request->setCaptureType('png');
$request->setTimeout(10000);
$request->setDelay(2000);
$png = $client->send($request)->getContent();
Storage::disk('public')->put("shots/{$id}.png", $png);
Every send() spawns a PhantomJS child process (~300–800 ms cold, more for JS-heavy pages). PDF generation on a web request will destroy your p95. Push it into a job:
class GenerateInvoicePdf implements ShouldQueue
{
public $timeout = 60;
public $tries = 3;
public function __construct(public int $invoiceId) {}
public function handle(PdfRenderer $renderer): void
{
$pdf = $renderer->pdf(route('invoices.show', $this->invoiceId));
Storage::put("invoices/{$this->invoiceId}.pdf", $pdf);
Invoice::whereKey($this->invoiceId)->update(['pdf_generated_at' => now()]);
}
}
Concurrency = number of queue workers; PhantomJS itself is single-page-per-process. Scale workers, not threads.
When you need to run JS in the page (scroll to bottom, click a "load more", read window.__DATA__), write a .proc file. It's a Twig-rendered PhantomJS script:
{# resources/phantomjs/extract.proc #}
var page = require('webpage').create();
page.viewportSize = { width: {{ width|default(1280) }}, height: 800 };
page.onConsoleMessage = function (msg) { console.log(msg); };
page.open('{{ url }}', function (status) {
var data = page.evaluate(function () {
return JSON.stringify(window.__INITIAL_STATE__ || {});
});
console.log(data);
phantom.exit();
});
Register it, then read the output from $response->getConsole():
$client->getProcedureLoader()->addLoader(
new \JonnyW\PhantomJs\Procedure\FileLoader(resource_path('phantomjs'))
);
$request->setProcedure('extract'); // matches extract.proc
This package and PhantomJS are both archived (last release 2018; PhantomJS died in 2018). Treat it as legacy maintenance, not a greenfield choice. It depends on old Symfony components and Twig — on PHP 8.x you will hit deprecations, and some builds fatal outright. If you're starting fresh, spatie/browsershot (headless Chrome) or chrome-php/chrome solve the same problems with a living upstream. If you inherit this package, isolate it behind your own interface so swapping the driver is a one-file change.
The binary is not bundled. InvalidExecutableException at runtime almost always means setPath() points at a directory instead of the executable, or the binary isn't on the box at all (Docker images frequently omit it). Pin the version — behaviour differs between 1.9 and 2.1.1.
proc_open is the transport layer. Shared hosts and hardened PHP-FPM configs routinely disable it in disable_functions. You'll get silent failures, not a clear error.
The cache dir must exist, be writable, and be cleaned. Compiled .proc files are cached there. Edit a procedure, forget to clear the cache, and you'll debug the wrong file for an hour. Also: screenshots and cookies land there, so prune it or it grows unbounded. Use storage/app/phantomjs — not /tmp in a container that gets recycled mid-flight.
setTimeout() and setDelay() are both in milliseconds and mean different things. timeout is the hard process kill; delay is a fixed sleep after page.open returns. For any page with client-side rendering, bump delay to 1500–3000 ms — a "blank PDF" is nearly always a race, not a bug in your template. There's no waitForSelector in the default procedure; if you need real synchronisation, write a custom .proc.
Modern sites will break PhantomJS, and it won't be your fault. PhantomJS 2.1.1 predates ubiquitous ES6, fetch, Promise, and modern TLS defaults. Expect SyntaxError on React/Vue bundles and TLS handshake failures against SNI-only hosts. The escape hatches (--ssl-protocol=any, --ignore-ssl-errors=true) work for the TLS side; there is no fix for the JS side other than transpiling the target page or switching tools.
getStatus() !== 200 covers a lot of ground. It returns the PhantomJS page status, which can be null/0 when the process died (segfault, OOM, binary missing). Always log $response->getConsole() and the engine stderr on failure — that's where real errors surface.
getContent() is binary for PDF and capture requests. Never return response()->json(['pdf' => $response->getContent()]) — you'll corrupt the bytes. Stream it (response()->streamDownload()) or write to storage and hand back a signed URL. Add --load-images=false when generating text PDFs; it's often a 2–3× speedup.
Client::getInstance() is a singleton with mutable state. In a queue worker that processes PDFs configured for A4 and screenshots configured for 1280×800, options set once leak into later jobs. Rebuild the client per job, or resolve it fresh from the container with bind rather than singleton if you vary config.
Testing: mock at your own wrapper boundary, not at Client. There's no built-in fake; attempting to unit-test the procedure compiler is rarely worth it. Assert that your job writes a non-empty file, and keep a single integration test that hits a local Blade route.
How can I help you explore Laravel packages today?