---
title: "How to Run PHP in a Sandbox"
description: "Bake the PHP runtime into a VM snapshot, boot one VM, and run many PHP scripts on it with an isolated, reusable runPhp() helper — then serve PHP over a public domain from the same snapshot."
url: "/docs/guides/run-php-in-a-sandbox"
---

This guide builds a reusable snapshot with the PHP CLI preinstalled, boots one VM from it, then wraps that VM in a `runPhp()` helper you can call as many times as you like — each call writes to a uniquely-named file, so runs never clash, sequentially or concurrently. The same snapshot then powers a long-running PHP web server routed to a public domain.


## Install the SDK

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
export FREESTYLE_API_KEY="your-api-key"
```


## Build a Snapshot with PHP

Create a VM from the minimal base image, install the PHP command-line interpreter with `apt-get`, then snapshot the machine so the install is baked in. Nothing is preinstalled, so install PHP explicitly — `php-cli` pulls in the `php` binary without a web server. Delete the builder VM once the snapshot is captured.

```ts
import { freestyle } from "freestyle";

const { vm: builder } = await freestyle.vms.create();

await builder.exec("apt-get update -qq");

const install = await builder.exec(
  "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq php-cli",
);
console.log(install.statusCode); // 0

const version = await builder.exec("php -v");
console.log(version.stdout); // PHP 8.4.21 (cli) ...

const { snapshotId } = await builder.snapshot();
await builder.delete();
```

`apt`-installed packages persist into the snapshot, so every VM you boot from `snapshotId` has the `php` binary ready immediately — you only pay the `apt-get` cost once.


## A Reusable runPhp() Utility

The VM is the reusable sandbox: create it once, then run as many snippets as you like on it. The helper takes the `vm` as its first argument, writes your source to a uniquely-named file, and runs it with `php`. Each call writes to `/tmp/main-<uuid>.php` via `crypto.randomUUID()`, so no two runs — sequential or concurrent — can clobber each other's source. The helper never creates or deletes a VM; lifecycle stays with the caller. `vm.exec` is synchronous: it runs the script to completion and returns `{ stdout, stderr, statusCode }`.

```ts
async function runPhp(vm, code: string) {
  const file = `/tmp/main-${crypto.randomUUID()}.php`;
  await vm.fs.writeTextFile(file, code);
  return await vm.exec(`php ${file}`);
}

const { vm } = await freestyle.vms.create({ snapshotId });

const out = await runPhp(
  vm,
  `<?php
echo "php " . PHP_VERSION . "\\n";
$nums = [1, 2, 3, 4, 5];
echo "sum=" . array_sum($nums) . "\\n";
`,
);

console.log(out.statusCode); // 0
console.log(out.stdout);
// php 8.4.21
// sum=15
```

Each `exec` returns `{ stdout, stderr, statusCode }`, where a `statusCode` of `0` means the program exited successfully. Reuse the same `vm` for the next snippet — there is no need to boot a new machine:

```ts
const greet = await runPhp(vm, `<?php echo strtoupper("hello"), "\\n";`);
console.log(greet.stdout.trim()); // HELLO
```


## Pass Arguments and Standard Input

The base `runPhp` is great for quick snippets, but it hands back a raw result and never checks `statusCode`, so a failed program looks the same as a successful one. Add a second, stricter helper — `runPhpStrict` — that also accepts command-line `args` and `stdin`, returns a typed result, and throws when the program exits non-zero so failures surface as exceptions instead of silent bad output. It is a distinct function with its own name, so both helpers can live side by side in the same script. Like `runPhp`, it takes the `vm` first and reuses it — only the per-call source and stdin files are unique.

```ts
async function runPhpStrict(
  vm,
  code: string,
  { args = [], stdin = "" }: { args?: (string | number)[]; stdin?: string } = {},
): Promise<{ stdout: string; stderr: string; statusCode: number }> {
  const id = crypto.randomUUID();
  const file = `/tmp/main-${id}.php`;
  const stdinFile = `/tmp/stdin-${id}`;
  await vm.fs.writeTextFile(file, code);
  if (stdin) await vm.fs.writeTextFile(stdinFile, stdin);

  const argv = args
    .map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
    .join(" ");
  const cmd = stdin
    ? `php ${file} ${argv} < ${stdinFile}`
    : `php ${file} ${argv}`;

  const r = await vm.exec(cmd);
  if (r.statusCode !== 0) {
    throw new Error(`runPhpStrict: php exited ${r.statusCode}: ${r.stderr ?? r.stdout}`);
  }
  return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", statusCode: r.statusCode };
}
```

Run several snippets on the **same** `vm`. Pass arguments and read them back from `$argv`:

```ts
const sum = await runPhpStrict(vm, `<?php
$total = 0;
foreach (array_slice($argv, 1) as $n) { $total += (int)$n; }
echo "total: $total\\n";
`, { args: [3, 4, 5] });

console.log(sum.stdout.trim()); // total: 12
```

Feed input on stdin and read it with `STDIN`:

```ts
const piped = await runPhpStrict(vm, `<?php
$total = 0;
while (($line = fgets(STDIN)) !== false) {
  foreach (preg_split('/\\s+/', trim($line)) as $tok) {
    if ($tok !== "") $total += (int)$tok;
  }
}
echo "sum=$total\\n";
`, { stdin: "3 4 5 6\\n" });

console.log(piped.stdout.trim()); // sum=18
```

Because every run uses a unique filename, you can even fire several at once on the one VM — they stay fully isolated:

```ts
const [a, b] = await Promise.all([
  runPhp(vm, `<?php echo "A " . (111 * 2) . "\\n";`),
  runPhp(vm, `<?php echo "B " . (222 * 2) . "\\n";`),
]);

console.log(a.stdout.trim(), "/", b.stdout.trim()); // A 222 / B 444
```

A program that exits non-zero now rejects the promise, so a `try`/`catch` around `runPhpStrict()` cleanly handles a failed run:

```ts
try {
  await runPhpStrict(vm, `<?php exit(3);`);
} catch (err) {
  console.log(err.message); // runPhpStrict: php exited 3: ...
}
```


## Run a Server

The same snapshot that runs one-off scripts can host a long-running PHP web server. Boot a fresh VM from `snapshotId` — it already has the `php` binary — and keep the `vmId` it returns for routing. Pass `idleTimeoutSeconds: null` so the server VM stays up instead of pausing when idle.

```ts
const { vm: server, vmId } = await freestyle.vms.create({
  snapshotId,
  idleTimeoutSeconds: null,
});
```

Write the app to `/srv/index.php`, then a **systemd** unit. systemd is PID 1 on the VM, so a unit is the durable way to run a server: it supervises the process and restarts it if it crashes. Units do not source a shell profile, so point `ExecStart` at the **absolute** `php` path. The server must bind `0.0.0.0` (not `127.0.0.1`) so the platform can route external traffic to it.

```ts
await server.fs.writeTextFile(
  "/srv/index.php",
  `<?php
header("Content-Type: text/plain");
echo "hello from php " . PHP_VERSION . "\\n";
`,
);

await server.fs.writeTextFile(
  "/etc/systemd/system/php-server.service",
  `[Unit]
Description=PHP built-in server
After=network.target

[Service]
ExecStart=/usr/bin/php -S 0.0.0.0:3000 -t /srv
WorkingDirectory=/srv
Restart=always

[Install]
WantedBy=multi-user.target
`,
);
```

Enable and start the unit, then **wait until it actually serves**. Poll `localhost:3000` until it returns `200` so you don't route a domain at a server that isn't listening yet.

```ts
await server.exec(
  "systemctl daemon-reload && systemctl enable --now php-server",
);

let ready = false;
while (!ready) {
  const probe = await server.exec(
    "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
  );
  ready = (probe.stdout ?? "").trim() === "200";
  if (!ready) await new Promise((r) => setTimeout(r, 1000));
}
```

Map a domain to the VM's port. `style.dev` subdomains need no DNS setup or verification, and each run gets a unique one.

```ts
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;

await freestyle.domains.mappings.create({
  domain,
  vmId,
  vmPort: 3000,
});
```

Fetch it from outside the VM:

```ts
const res = await fetch(`https://${domain}`);
console.log(res.status);
console.log(await res.text());
// hello from php 8.4.21
```

The request lands on your `php -S` process inside the VM. Edit `/srv/index.php` and restart the unit with `systemctl restart php-server` whenever you want to ship a new version, or snapshot the running server so every VM you boot from it comes up already answering HTTP.


## Stream the Server's Logs

`vm.exec()` buffers a command and only returns once it finishes, so it can't show a long-running service's output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit's journal. `onData` delivers the bytes as they arrive:

```ts
const session = await server.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live log lines
});

// Follow the service; new lines stream in until you detach.
session.write("journalctl -u php-server -f\n");

// session.detach() drops your handle — the service keeps running in the VM.
```
