nacmartin/phpexecjs
Execute JavaScript from PHP with nacmartin/phpexecjs. This lightweight package bridges PHP and Node.js to evaluate JS snippets, pass data between languages, and add JavaScript execution to Laravel apps through a simple API.
nacmartin/phpexecjs is a small PHP port of the Ruby ExecJS gem: you hand it a string of JavaScript, it shells out to an installed JS runtime (Node.js, PhantomJS), and returns whatever the script printed to stdout. It is deliberately thin — think "subprocess wrapper with a friendly API", not a sandbox or an embedded engine.
Minimal setup:
composer require nacmartin/phpexecjs
node -v # or: phantomjs -v
which node # confirm the binary PHP will see
First use case — evaluate an expression or call a function you control:
use Nacmartin\PhpExecJs\PhpExecJs;
$js = new PhpExecJs();
echo $js->evalJs("'hello' + ' ' + 'world'"); // hello world
If you need to pin a runtime or a binary, use the factory and pass options (check src/.../Runtime in the repo for the shipped runtime names and their option keys):
$js = PhpExecJs::create('NodeJs', ['node_bin' => '/usr/local/bin/node']);
Where to look first:
Runtime classes — these show exactly how the child process is invoked, which is where 90% of your debugging will happen.evalJs) — the surface area is tiny, so read it end-to-end before building abstractions on top.Good first use case: calling a self-contained JS helper (a slugifier, a template renderer, a validation routine) that already exists on the frontend, so you don't maintain a second implementation in PHP.
Expression and function evaluation. evalJs returns a string; cast explicitly and prefer returning JSON from JS over relying on JS toString:
$result = $js->evalJs("JSON.stringify({ok: true, count: 3})");
$data = json_decode($result, true);
Context injection via JSON, not string building. Seed globals from PHP data with json_encode, which handles escaping correctly:
$context = ['user' => ['id' => 1, 'name' => 'Ada']];
$prelude = 'var context = ' . json_encode($context) . ';';
$name = $js->evalJs($prelude . 'context.user.name.toUpperCase()');
Bundling a library once, calling it many times. Read the bundle from disk once, cache the source string, and append the call each time:
$bundle = Cache::rememberForever('ssr-bundle', fn () =>
file_get_contents(resource_path('js/ssr.js'))
);
$html = $js->evalJs(
$bundle . "\nrender(" . json_encode($props) . ");"
);
Server-side rendering. Build a server bundle with Vite/esbuild (ssr: true), boot it with evalJs, and cache rendered HTML keyed by a hash of the props. This is the package's most common real-world use: one render call per request, not one per component.
Laravel integration. Bind a singleton in a service provider so controllers never touch the package directly, and keep the binary path in config:
// config/execjs.php
return [
'runtime' => 'NodeJs',
'node_bin' => env('EXECJS_NODE_BIN', '/usr/bin/node'),
'timeout' => 5,
];
// AppServiceProvider::register()
$this->app->singleton(JsRuntime::class, fn () => new JsRuntime(
PhpExecJs::create(config('execjs.runtime'), [
'node_bin' => config('execjs.node_bin'),
])
));
Wrap the package in your own JsRuntime class (evaluate + timeout + JSON decode + logging). That wrapper is where you add a timeout and stderr capture, because the package itself is minimal.
Offload heavy work. Put anything beyond a trivial eval on a queued job or a console command. Keep the synchronous request path for small, fast calls only.
Testing. Don't hit a real Node process in unit tests. Put JsRuntime behind an interface and bind a fake in TestCase::setUp. Reserve one integration test (tagged/slow) that runs against real Node to catch bundling regressions.
Custom runtimes. The package is small enough to extend; if you need Deno, Bun, or QuickJS, implement a runtime that follows the same contract as the shipped NodeJs/PhantomJs runtimes and hand it to the factory.
evalJs spawns a new process. Expect tens to hundreds of milliseconds of overhead, plus Node startup. Never call it inside a loop over thousands of records — batch inside JS or cache the result.json_encode context instead of interpolating strings into code.undefined, functions, and cyclic objects serialize poorly; always end the script with JSON.stringify(...) (or console.log a JSON string) and json_decode in PHP.try/catch, assert on decoded structure, and log stdout+stderr together. Deliberately break a script during setup to see exactly how failure surfaces.JsRuntime, or swap in a symfony/process wrapper for full control over timeout and stderr).PATH than your shell. Set an absolute node_bin in config and deploy it via env.require/read it inside JS (require('fs').readFileSync(...)).NODE_OPTIONS=--max-old-space-size=...; set it in the process environment, not in the JS.composer.lock and run a compatibility test on your PHP version before adopting. For new projects, weigh alternatives: symfony/process + Node directly (best control), the V8js extension (in-process, faster, but a bigger install), or a persistent Node sidecar for high-throughput SSR.How can I help you explore Laravel packages today?