Learn to implement a secure PHP sandbox for dynamic code execution in Laravel. Stop using eval() and discover how VM isolation protects your production apps.
Running user-supplied code in a Laravel application is one of those features that sounds like a great "power user" addition until you realize you’ve essentially handed the keys to your server to a stranger. Last year, I was tasked with building a dynamic workflow engine where users could define small business rules in PHP snippets. We initially tried a whitelist-based filter, but it took about two days for a teammate to bypass it with a clever base64_decode payload.
We quickly scrapped the filter-based approach. If you’re building features that require dynamic execution, you need a proper PHP sandbox that provides actual VM isolation. Here’s how we architected a secure, containerized solution for Laravel.
Using eval() or assert() to execute user code is a non-starter. Even if you restrict functions via disable_functions in your php.ini, you aren't protecting the underlying system or the application's memory space.
When you allow dynamic execution, you are opening the door to:
.env file or SSH keys.We needed a way to run this code in a "black box" where it couldn't see the filesystem, the network, or the rest of the Laravel application.
Instead of trying to secure the runtime, we decided to offload the execution to a short-lived, isolated container. We used Docker, but you could achieve similar results with WebAssembly (Wasm) runtimes if you're feeling adventurous.
The architecture looks like this:
stdout, which the Laravel job captures and returns.We created a simple driver to manage the container lifecycle. We use the symfony/process component to interact with the Docker CLI.
PHPpublic function execute(string $code): string { $containerName = 'sandbox_' . bin2hex(random_bytes(8)); $process = new Process([ 'docker', 'run', '--rm', '--network', 'none', '--memory', '64m', '--cpus', '0.5', '--read-only', 'php:8.2-cli-alpine', 'php', '-r', $code ]); $process->run(); if (!$process->isSuccessful()) { throw new SandboxExecutionException($process->getErrorOutput()); } return $process->getOutput(); }
This setup effectively limits the execution to 64MB of RAM and 50% of a single CPU core. By using --network none, we ensure the code can't call out to an external API or hit your internal services.
The biggest challenge with a PHP sandbox is getting data in and out. Since the container is isolated, you can't just pass Eloquent models into the sandbox. We had to serialize the data into a JSON payload and inject it as an environment variable or a temporary file.
If you are dealing with complex objects, I highly recommend looking into how to handle Laravel Serialization: Architecting Deterministic Payloads for High-Performance Queues to ensure your data transfer is type-safe and predictable. Don't try to pass raw PHP objects; stick to scalar types or DTOs.
This approach isn't free. Every execution requires spinning up a container, which adds latency. In our testing, the overhead was around 150-200ms per request. That's fine for background jobs, but it would kill your p99 performance if you tried to run this during a synchronous request cycle.
If you find yourself needing to run this in a synchronous loop, you might want to look into Laravel Tail Latency: Implementing Speculative Execution Middleware to prevent the sandbox overhead from blocking your entire request lifecycle.
Can I use a library like php-v8js instead?
V8JS is great for JavaScript, but for PHP-in-PHP, it's brittle. Containerization is more portable and easier to patch when a new CVE hits the PHP engine.
What about persistent storage? Don't. If the user needs to save state, return the data from the sandbox and let your Laravel application decide what to persist. Keep your sandbox stateless.
How do I handle timeouts?
The symfony/process component allows you to set a timeout on the process. Always set this. We default to 2 seconds, which is more than enough for business logic.
Isolation is a spectrum. We chose containers because they’re easy to reason about and fit well within our existing infrastructure. Next time, I’d probably look into using a dedicated Wasm runtime like ext-wasm, as it offers even lower overhead and tighter memory sandboxing. But for now, container-based VM isolation gives us the safety we need without the complexity of writing custom C extensions.
Security isn't about being perfect; it's about making the cost of an attack higher than the value of the exploit. By moving the sandbox out of your main process, you’ve already won half the battle.
Master Laravel service container binding to build decoupled, testable apps. Learn how to map interfaces to concrete classes for cleaner architecture.