qpautrat/reactjs-php-bundle
A Symfony bundle that integrates React.js into your PHP application, letting you render React components server-side and manage their assets from Twig templates with minimal setup and no separate JavaScript build pipeline.
Confirm the dependency before anything else. This bundle is a thin Symfony wrapper around reactjs/react-php-v8js, which executes JavaScript through the V8Js PHP extension. No extension, no package.
php -r 'var_dump(extension_loaded("v8js"));'
If that prints false, stop and solve ops first — either pecl install v8js (needs libv8 headers, painful on some distros) or, far easier, build on a Docker image that ships the extension. This one requirement eliminates most shared hosting.
Install and register:
composer require qpautrat/reactjs-php-bundle
// AppKernel.php (Symfony 2/3 era)
new Qpautrat\ReactjsPhpBundle\QpautratReactjsPhpBundle(),
# config.yml — typical shape; confirm exact keys in the README
qpautrat_reactjs_php:
react_path: "%kernel.root_dir%/../node_modules/react/dist/react.js"
components_path: "%kernel.root_dir%/../web/js/components.js"
Where to look first: the bundle is small. Find the renderer service and the Twig extension (bin/console debug:container | grep -i react), then read ReactJS.php from the underlying library — setComponent() + getMarkup() is the entire API you're configuring.
First use case: server-render one presentational component (a product card, a comment, a price block) into a Twig template so crawlers and first paint get real markup without standing up a Node SSR service.
{{ react_component('ProductCard', { title: product.title, price: product.price }) }}
If you're in Laravel: don't fight the bundle. composer require reactjs/react-php-v8js, bind ReactJS as a singleton in a service provider, and expose a Blade directive. You lose the config sugar and gain a working integration.
Precompile everything. The bundle just evals your components_path file inside V8. JSX is not transpiled at runtime. Use webpack/Babel to emit one bundle where each component is assigned to a global the library can reach (window.ProductCard = ...) — not a module export graph.
Controller → props → renderer. Build props as a plain PHP array, hand it to the renderer, echo the markup. Keep it as a dedicated renderer service of your own:
class ReactRenderer
{
public function __construct(private ReactJS $react) {}
public function render(string $component, array $props = []): string
{
try {
$this->react->setComponent($component, $props);
return $this->react->getMarkup();
} catch (\Throwable $e) {
$this->logger->error('SSR failed', ['component' => $component, 'e' => $e]);
return ''; // container falls back to client render
}
}
}
Hydrate, don't re-render. Emit the server markup into a div with an id, embed the same props as JSON, and call ReactDOM.hydrate(<Component {...props} />, el) on the client. Server and client must agree byte-for-byte or React throws away the markup.
<div id="product-{{ product.id }}">{{ react_component('ProductCard', props) }}</div>
<script>hydrate('product-{{ product.id }}', {{ props|json_encode|raw }});</script>
Cache aggressively. Booting V8 and evaluating React per request is expensive. Wrap the renderer in a cache keyed by component + md5(json_encode($props)). For mostly-static components this turns SSR into a cache hit.
Wrap, don't scatter. Inject your ReactRenderer into controllers/services rather than calling the bundle's Twig function everywhere. It gives you one place for logging, fallback, and caching, and makes the Twig helper just sugar.
Version lock-in is the big one. react-php-v8js targets an old React (React.renderComponentToString, pre-16). Modern React moved to ReactDOMServer.renderToString. Verify which React build your react_path points at and patch the JS bootstrap if needed — many "it returns empty string" reports are just a mismatched server build.
Never interpolate user data into JS source. Only pass props through json_encode/the PHP array API. Anything that lands in the evaluated string is a code-execution vector inside your PHP process.
Props must be JSON-serializable. No closures, resources, or circular references. Dates become strings; be explicit.
Hydration mismatches come from anything non-deterministic during SSR: Date.now(), Math.random(), window/localStorage access at render time. Guard browser-only code in componentDidMount/useEffect, not render.
Watch memory and time limits. V8 contexts are heavy; add V8Js::setTimeLimit() and a sane memory_limit. Log V8 exceptions — they surface as opaque PHP errors otherwise.
Symfony compatibility. The bundle predates Flex and Symfony 5+. On modern Symfony (or Laravel), use reactjs/react-php-v8js directly; the bundle saves you maybe 20 lines of config.
Debugging checklist: call getMarkup() in a bare controller before wiring Twig; confirm the component is actually a global after your bundle loads ($react context, V8Js::executeString('typeof ProductCard')); check that components_path isn't a webpack module chunk with require calls it can't resolve.
Extension points: replace the renderer service with your own implementation (caching, fallback, multi-component batching) and decorate the Twig extension. This is the intended way to add behavior — the bundle itself has very little code to fork.
How can I help you explore Laravel packages today?