# How Do I Set Up a PHP Dev Environment in a Cloud Sandbox?

> A new sandbox has no PHP. apt-get install php-cli gave Ubuntu's PHP 8.3.6 in 11 seconds; the ondrej/php PPA that php.net lists gave 8.5.12 in 56. Install Composer with its signature check, serve Laravel on 127.0.0.1, host it privately, and trust the 127.0.0.1 proxy or Laravel builds http:// links.

Source: https://sandboxer.com/guides/php-dev-environment-in-a-cloud-sandbox. Updated 2026-10-27.

**Short answer:** A new sandbox has no PHP. apt-get install php-cli gave Ubuntu's PHP 8.3.6 in 11 seconds; the ondrej/php PPA that php.net lists gave 8.5.12 in 56. Install Composer with its signature check, serve Laravel on 127.0.0.1, host it privately, and trust the 127.0.0.1 proxy or Laravel builds http:// links.

We ran every command on this page on prod on 2026-10-27 in eu-central, on `small` sandboxes (2 vCPU, 4 GB). The project is the [laravel/laravel](https://github.com/laravel/laravel) application skeleton at tag v13.9.0 (commit `3514078`), which pulls 111 Composer packages including Laravel 13.

## Results

| Step | Time on `small` |
| - | - |
| `apt install php-cli` and 6 extensions (Ubuntu, PHP 8.3.6) | 11.2 s |
| Add the ondrej/php PPA, install PHP 8.5.12 and the same extensions | 55.6 to 56.3 s |
| Install Composer 2.10.3, signature checked | 1.3 s |
| `composer install`, empty cache (111 packages) | 6.8 s |
| `composer install`, warm cache, no `vendor/` (3 runs) | 1.86, 2.10, 1.99 s |
| `composer install`, nothing to do | 1.06 s |
| `php artisan test` | 0.32 s |
| `sbx new --from` the named snapshot | 3.2 s |
| In the new sandbox: `composer install`, cache only (3 runs) | 1.79, 1.96, 1.95 s |
| 4 parallel 1-second requests, `artisan serve` with 1 worker | 4.2 s |
| The same with `PHP_CLI_SERVER_WORKERS=4 … --no-reload` (3 runs) | 2.2, 3.2, 2.2 s |

The lesson: Composer is quick, so in PHP the snapshot saves you the PHP install, not the packages. The PPA install is the slow part, at almost a minute.

## 1. There is no PHP yet

```bash
sbx new --name phpdev-1 --type small
sbx exec sb_9btgmurcx0 "which php composer"
```

`which` printed nothing: neither `php` nor `composer` is in the image on 2026-10-27. Everything else that is there is listed in [What's pre-installed in a sandbox](/guides/whats-preinstalled-in-a-sandbox). You have passwordless sudo, so you have two choices for PHP.

## 2. Pick a PHP: Ubuntu's 8.3 or the PPA's 8.5

[php.net's supported versions page](https://www.php.net/supported-versions.php) shows 8.5 (released November 2025) as the newest branch on 2026-10-27, and 8.3 as security fixes only, until 31 December 2027. The sandbox runs Ubuntu 24.04, whose own package is 8.3.

**Ubuntu's PHP 8.3.** This is the "OS default" option on [php.net's download page](https://www.php.net/downloads.php). We added the extensions Laravel and Composer need:

```bash
sudo apt-get update -qq
sudo apt-get install -y php-cli php-xml php-mbstring php-curl php-sqlite3 php-zip php-intl unzip
php -v | head -1
```

```text
PHP 8.3.6 (cli) (built: Oct 19 2026 15:16:58) (NTS)
```

It took 11.2 seconds. Laravel 13 needs PHP `^8.3`, so this is enough for the skeleton.

**PHP 8.5 from the PPA.** For Ubuntu and a newer version, php.net's download page gives the [ondrej/php PPA](https://launchpad.net/~ondrej/+archive/ubuntu/php). The page notes that these are third-party builds with patches, including telemetry in the Apache 2 and PHP-FPM modules; the CLI is what this page uses. On a fresh sandbox:

```bash
sudo apt-get install -y software-properties-common
sudo LC_ALL=C.UTF-8 add-apt-repository ppa:ondrej/php -y
sudo apt-get update -qq
sudo apt-get install -y php8.5-cli php8.5-xml php8.5-mbstring php8.5-curl php8.5-sqlite3 php8.5-zip php8.5-intl unzip
php -v | head -1; update-alternatives --list php
```

```text
PHP 8.5.12 (cli) (built: Oct 21 2026 14:37:30) (NTS)
/usr/bin/php8.5
```

It took 55.6 seconds, most of it adding the repository.

**Don't mix the two without checking `php -v`.** In our first sandbox we installed Ubuntu's `php-cli` first and then the `php8.5-*` packages. The PPA upgraded the `php-cli` metapackage, which pulled in PHP 8.4, and `php` pointed at 8.4, not 8.5:

```text
PHP 8.5.12 (cli) (built: Oct 21 2026 14:37:30) (NTS)     # php8.5 -v
PHP 8.4.27 (cli) (built: Oct 21 2026 14:35:21) (NTS)     # php -v
/usr/bin/php.default
/usr/bin/php8.3
/usr/bin/php8.4
/usr/bin/php8.5
```

Three PHPs were installed. Pick the default by hand:

```bash
sudo update-alternatives --set php /usr/bin/php8.5
php -v | head -1
```

```text
update-alternatives: using /usr/bin/php8.5 to provide /usr/bin/php (php) in manual mode
PHP 8.5.12 (cli) (built: Oct 21 2026 14:37:30) (NTS)
```

Any version stays reachable by name (`php8.3 artisan test`), which is handy for testing a library on two versions.

## 3. Install Composer with the signature check

[getcomposer.org](https://getcomposer.org/download/) asks you not to copy its installer snippet, because the hash in it changes with each installer. Its [programmatic install](https://getcomposer.org/doc/faqs/how-to-install-composer-programmatically.md) fetches the current signature instead, so it works in a script:

```bash
EXPECTED="$(curl -fsSL https://composer.github.io/installer.sig)"
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
ACTUAL="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"
[ "$EXPECTED" = "$ACTUAL" ] && echo "installer signature OK" || exit 1
sudo php composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer
rm composer-setup.php
composer --version; composer config --global cache-dir
```

```text
installer signature OK
Composer version 2.10.3 2026-08-27 13:34:23
/home/user/.cache/composer
```

It took 1.3 seconds. Composer 2.10.3 was the latest stable release on 2026-10-27. The cache is under `/home/user`, so it survives `sbx stop` and goes into a snapshot.

## 4. Install a real project, cold and warm

Long commands run past the 30-second default of `sbx exec`, so we ran the script with `--detach` and read its log:

```bash
git clone -q https://github.com/laravel/laravel app && cd app && git checkout -q 3514078f98dd
composer install --no-interaction --no-progress
```

```text
…
6.751 s
packages: 111
75M	vendor
29M	/home/user/.cache/composer
== composer install, warm cache, no vendor (3 runs)
1.859 s
2.095 s
1.992 s
== composer install, nothing to do
1.057 s
```

A cold install of 111 packages took 6.8 seconds; from the cache, about 2. Even "nothing to do" takes a second, because Composer still runs the skeleton's `post-autoload-dump` scripts (`artisan package:discover`). Then the app setup and its test suite:

```bash
cp .env.example .env && php artisan key:generate -q
touch database/database.sqlite && php artisan migrate --force
php artisan test
```

```text
  0001_01_01_000000_create_users_table ........................... 9.72ms DONE
  0001_01_01_000001_create_cache_table ........................... 5.42ms DONE
  0001_01_01_000002_create_jobs_table ............................ 8.44ms DONE
…
  Tests:    2 passed (2 assertions)
  Duration: 0.15s
```

SQLite needs no database server, so the skeleton runs as cloned. The same tests passed on 8.5 with `php8.5 artisan test`, and `composer check-platform-reqs` passed on 8.5.

## 5. Serve it on a private HTTPS URL

`php artisan serve` runs PHP's built-in web server on `127.0.0.1:8000`. That is what you want: a sandbox has a public IPv6 address, so don't bind `0.0.0.0`. Start it detached and host it with `--localhost`:

```bash
sbx exec sb_9btgmurcx0 --detach "cd app && php artisan serve > serve.log 2>&1"
sbx host sb_9btgmurcx0 8000 --localhost
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_9btgmurcx0","port":8000,"url":"https://jade-formal-clam-8000.box.sandboxer.com?_token=…","access":"private","isProtected":true,"localhost":true}
```

Without the token the URL returned `401`. With `?_token=…` it returned `302`, set a cookie and sent us to the same URL without the token; then `/` returned `200`. Inside the sandbox, the pre-installed `host 8000` does the same. How private URLs, tokens and cookies work is in [Hosting](/docs/hosting).

### Fix http:// links behind the proxy

We added a route that prints what Laravel thinks of the request:

```php
// routes/web.php
Route::get('/whereami', fn (Illuminate\Http\Request $r) => [
    'secure' => $r->isSecure(),
    'host' => $r->getHost(),
    'url' => url('/dashboard'),
    'xfp' => $r->header('X-Forwarded-Proto'),
]);
Route::get('/go', fn () => redirect('/whereami'));
```

Through the hosted URL, as cloned:

```text
{"secure":false,"host":"jade-formal-clam-8000.box.sandboxer.com","url":"http:\/\/jade-formal-clam-8000.box.sandboxer.com\/dashboard","xfp":"https"}
location: http://jade-formal-clam-8000.box.sandboxer.com/whereami
```

The proxy sends `X-Forwarded-Proto: https`, but Laravel ignores it from an untrusted proxy, so `url()`, `asset()` and redirects come out as `http://`. The proxy answers `http://` with a `308` to `https://`, so plain links still work, but a browser blocks `http://` scripts and styles on an HTTPS page as mixed content. Laravel's fix is [trusted proxies](https://laravel.com/docs/13.x/requests#configuring-trusted-proxies). With `--localhost` the proxy reaches the app from `127.0.0.1`, so trust only that address in `bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: '127.0.0.1');
})
```

```text
{"secure":true,"host":"jade-formal-clam-8000.box.sandboxer.com","url":"https:\/\/jade-formal-clam-8000.box.sandboxer.com\/dashboard","xfp":"https"}
location: https://jade-formal-clam-8000.box.sandboxer.com/whereami
```

No restart was needed: `artisan serve` loads the app on every request.

### More than one request at a time

PHP's built-in server runs [one single-threaded process](https://www.php.net/manual/en/features.commandline.webserver.php) by default, so a slow request blocks the next one. We added a route that sleeps one second and sent four requests at once through the hosted URL:

```text
ok 9433
ok 9433
ok 9433
ok 9433
4.226 s
```

One process, served in turn. PHP's `PHP_CLI_SERVER_WORKERS` variable forks more workers, but `artisan serve` ignores it unless you also pass `--no-reload`. Without the flag we still got one process and 4.25 seconds. With it:

```bash
cd app && PHP_CLI_SERVER_WORKERS=4 php artisan serve --no-reload
```

```text
ok 9481 ok 9477 ok 9479 ok 9479 2.226 s
ok 9479 ok 9480 ok 9479 ok 9479 3.226 s
ok 9479 ok 9480 ok 9481 ok 9479 2.219 s
```

Four workers took 2.2 to 3.2 seconds, not 1: the workers share one listening socket and the kernel doesn't spread requests evenly. With `--no-reload`, restart the server after you change `.env`. Inside the sandbox, the welcome page took 5 to 11 ms on `127.0.0.1`.

## 6. Check code over SSH

With `sbx ssh-config --install` done once on your laptop, any SSH tool reaches the sandbox by id. In the sandbox we later started from the snapshot (step 7), we linted a broken file and a good one:

```bash
ssh sb_pqvf8vbouy.sbx 'cd app && php -l /tmp/bad.php; php -l routes/web.php'
```

```text
Errors parsing /tmp/bad.php
PHP Parse error:  Unclosed '[' on line 2 in /tmp/bad.php on line 3
No syntax errors detected in routes/web.php
```

In VS Code or Cursor, **Remote-SSH** to `sb_pqvf8vbouy.sbx` and open `/home/user/app`; PHP extensions installed on the remote side use the sandbox's `php`. We tested the `ssh` command, not the editor UI. Setup and troubleshooting are in [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor).

## 7. Save it as a named snapshot and start from it

```bash
sbx snapshot sb_9btgmurcx0 phpdev-cache
sbx new --name phpdev-2 --type small --from phpdev-cache
```

The save took 2.1 seconds and the new sandbox was ready in 3.2. It came with everything on the disk: the PHPs, the `update-alternatives` choice, Composer, its cache, the clone with `vendor/` and `.env`, and the trusted-proxy edit:

```text
PHP 8.5.12 (cli) (built: Oct 21 2026 14:37:30) (NTS)
Composer version 2.10.3 2026-08-27 13:34:23
== composer install, as saved
1.926 s
== composer install, cache only (rm -rf vendor), 3 runs
1.789 s
1.956 s
1.952 s
```

We started `PHP_CLI_SERVER_WORKERS=4 php artisan serve --no-reload` again and hosted port 8000. Hosting the new sandbox gave a new URL and token, and `/whereami` reported `"secure":true` at once. The server process itself is not in a named snapshot; start it again, or run it as a systemd unit. For naming the snapshot by a hash of `composer.lock` and rebuilding it when that changes, see [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot).

## FAQ

### Which PHP version does a Sandboxer sandbox have?

None on 2026-10-27; PHP isn't in the image. `sudo apt install php-cli` gives Ubuntu 24.04's PHP 8.3.6, and php.net's ondrej/php PPA option gave 8.5.12. Check `php -v` after installing.

### Why does my Laravel app make http:// links on the HTTPS URL?

The hosting proxy terminates HTTPS and sends `X-Forwarded-Proto: https`, which Laravel ignores until the proxy is trusted. Add `$middleware->trustProxies(at: '127.0.0.1')` in `bootstrap/app.php` when the port is hosted with `--localhost`.

### Why does php -v show 8.4 after I installed php8.5?

Installing PPA packages over Ubuntu's `php-cli` upgraded that metapackage, which pulled in PHP 8.4 as the default. Run `sudo update-alternatives --set php /usr/bin/php8.5`, or install only the `php8.5-*` packages on a fresh sandbox.

### Is it worth snapshotting vendor and the Composer cache?

For the time, barely: a cold `composer install` of 111 packages took 6.8 seconds and a warm one about 2. The snapshot pays off for the PHP install, which took 56 seconds from the PPA, and for your edits and database.

### Can artisan serve handle parallel requests?

Only with `PHP_CLI_SERVER_WORKERS` set and `--no-reload`. Without the flag, artisan serve runs one process. It is a development server either way; php.net says not to use it on a public network.

## Related

- [Set up a Python dev environment in a cloud sandbox](/guides/python-dev-environment-in-a-cloud-sandbox)
- [Run WordPress in a cloud sandbox](/guides/run-wordpress-in-a-sandbox)
- [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot)
- [Hosting](/docs/hosting)
