Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,30 @@ Do you have more questions (❓)? Let's move to [FAQ](#%EF%B8%8F-faq).

## 👨‍💻 `vercel dev`

For running `vercel dev` properly, you need to have PHP installed on your computer, [learn more](errors/now-dev-no-local-php.md).
But it's PHP and as you know PHP has built-in development server. It works out of box.
`vercel dev` runs your PHP functions using a local PHP CLI installation. Install PHP and the extensions your application needs, then run from your project root:

```sh
# For Composer projects, install dependencies locally first:
composer install
vercel dev
```

PHP must be available on `PATH`. Alternatively, set `VERCEL_PHP_EXECUTABLE` to the full path of your PHP executable. See [local PHP setup](errors/now-dev-no-local-php.md).

Vercel handles `vercel.json` routing and supplies its development environment variables. Each matched PHP request starts a loopback-only PHP built-in server on an available port, with that function's entrypoint as its router. Vercel shuts down the server after the response. The working directory and default document root are the project root; `VERCEL_PHP_DOCROOT` can select a different document root.

Local development uses your installed PHP version, extensions and configuration, not the bundled Linux deployment runtime. If `api/php.ini` exists, the runtime appends the `api` directory to PHP's configuration scan path, so `api/*.ini` overrides load after your host's primary configuration. Existing `PHPRC` and `PHP_INI_SCAN_DIR` settings are preserved. Use portable directives and configure local extension paths separately. Composer installation and the `composer run vercel` build script are not run automatically by the dev hook. Run any setup or generation your application needs before starting development.

This supports local HTTP development, not Lambda resource limits or production isolation. Servers are request-scoped, so do not rely on PHP process state between requests. The runtime disables PHP's extra CLI server workers to keep process cleanup bounded.

You can still bypass Vercel and use [PHP's built-in server](https://www.php.net/manual/en/features.commandline.webserver.php):

```sh
php -S localhost:8000 api/index.php
```

That command does not apply Vercel routes or load Vercel environment variables. `api/index.php` runs as a router on every request; return `false` from the router to serve a requested static file directly. Use your framework's development server when appropriate. PHP's built-in server is for development, not public production hosting.

## 👀 Demo

- official - https://php.vercel.app/
Expand Down Expand Up @@ -366,11 +383,7 @@ All files in root folder are uploaded to Vercel, use `.vercelignore` to exclude
<details>
<summary>9. How to develop locally?</summary>

I think the best way at this moment is use [PHP Development Server](https://www.php.net/manual/en/features.commandline.webserver.php).

```
php -S localhost:8000 api/index.php
```
Install local PHP and any Composer dependencies, then use `vercel dev`. See the [`vercel dev` section](#-vercel-dev) for configuration and the standalone PHP-server alternative.

</details>

Expand Down
50 changes: 14 additions & 36 deletions errors/now-dev-no-local-php.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,25 @@
# It looks like you don't have PHP on your machine.
# Local PHP is required for `vercel dev`

**Why This Error Occurred**
The runtime uses your installed PHP CLI for local development. It does not run the bundled Linux deployment binary on your computer. PHP-CGI is not required.

You ran `now dev` on a machine where PHP is not installed.
For the time being, this runtime requires a local PHP installation to run the runtime locally.
Install a supported PHP CLI version using your operating system's package manager or the [PHP installation instructions](https://www.php.net/manual/en/install.php). On Windows, install a native PHP build and its required Visual C++ runtime.

**Possible Ways to Fix It**
Check the executable and configuration in the terminal where you run Vercel:

1. Install PHP to your computer

**OSX**

```
brew install php@7.4
```

**Ubuntu**

```
apt-get -y install apt-transport-https lsb-release ca-certificates
wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg
sh -c 'echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list'
apt-get update
apt-get install php7.4-cli php7.4-cgi php7.4-json php7.4-curl php7.4-mbstring
```sh
php -v
php --ini
```

**Fedora**
Add PHP to `PATH`, or set `VERCEL_PHP_EXECUTABLE` to the full executable path in that terminal's environment. For example, in PowerShell:

```
yum install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
yum install https://rpms.remirepo.net/enterprise/remi-release-7.rpm
yum install yum-utils
yum-config-manager --enable remi-php74
yum update
yum install php74-cli php74-cgi php74-json php74-curl php74-mbstring
```powershell
$env:VERCEL_PHP_EXECUTABLE = 'C:\php\php.exe'
vercel dev
```

2. Start PHP built-in Development Server

```sh
php -S localhost:8000 api/index.php
```
Install the extensions your application requires. Startup warnings about missing DLLs or shared libraries usually mean your local `php.ini` references another installation. Correct those paths or use `PHPRC` to select a local configuration. The runtime preserves your host's configuration and appends `api/*.ini` project overrides when `api/php.ini` exists; it does not merge the Linux runtime's defaults.

**Check that php is in the path**
For Composer projects, run `composer install` locally before starting `vercel dev`. The dev hook does not install dependencies or run Composer build scripts automatically.

If you do have installed PHP but still get this error, check that PHP executable is added to the PATH environment variable.
See the [README local development section](../README.md#-vercel-dev) for routing, environment variables, document roots and the standalone PHP-server alternative.
146 changes: 146 additions & 0 deletions src/dev-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { spawn, ChildProcess } from 'child_process';
import { promises as fs } from 'fs';
import net from 'net';
import path from 'path';
import type { StartDevServer } from '@vercel/build-utils';

// Windows treats environment names case-insensitively, but Node's child_process
// only passes the first spelling. Merge overrides without retaining duplicates.
function localEnvironment(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const source of [process.env, overrides]) {
for (const [key, value] of Object.entries(source)) {
env[process.platform === 'win32' ? key.toUpperCase() : key] = value;
}
}
// One PHP process per invocation. Worker children would escape pid cleanup.
for (const key of Object.keys(env)) {
if (key.toUpperCase() === 'PHP_CLI_SERVER_WORKERS') delete env[key];
}
return env;
}

async function availablePort(): Promise<number> {
const server = net.createServer();
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
reject(new Error('Unable to allocate a local PHP server port.'));
return;
}
server.close(error => error ? reject(error) : resolve(address.port));
});
});
}

function stop(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null || !child.pid) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
child.kill('SIGKILL');
}, 1000);
const deadline = setTimeout(() => {
cleanup();
reject(new Error(`Unable to stop local PHP process ${child.pid}.`));
}, 5000);
const cleanup = () => {
clearTimeout(timer);
clearTimeout(deadline);
child.removeListener('close', closed);
};
const closed = () => { cleanup(); resolve(); };
child.once('close', closed);
child.kill();
});
}

function ready(child: ChildProcess, port: number): Promise<void> {
return new Promise((resolve, reject) => {
let stderr = '';
let socket: net.Socket | undefined;
const timeout = setTimeout(() => finish(new Error('Local PHP server startup timed out.')), 10000);
const finish = (error?: Error) => {
clearTimeout(timeout);
socket?.destroy();
child.removeListener('error', failed);
child.removeListener('close', closed);
child.stderr?.removeListener('data', output);
error ? reject(error) : resolve();
};
const failed = (error: Error) => finish(new Error(
`Unable to start local PHP. Install PHP CLI and add it to PATH, or set VERCEL_PHP_EXECUTABLE. ${error.message}`
));
const closed = (code: number | null) => finish(new Error(
`Local PHP server exited before becoming ready (code ${code}). ${stderr.trim()}`
));
const output = (data: Buffer) => {
stderr = (stderr + data.toString()).slice(-8192);
// Wait for PHP's own startup confirmation before probing. A different
// process could have claimed the allocated port between close and spawn.
if (!socket && /Development Server .*started/.test(stderr)) {
socket = net.connect(port, '127.0.0.1');
socket.once('connect', () => finish());
socket.once('error', error => finish(error));
}
};
child.once('error', failed);
child.once('close', closed);
child.stderr?.on('data', output);
});
}

export const startDevServer: StartDevServer = async ({
entrypoint, workPath, meta = {}, publicDir, onStdout, onStderr,
}) => {
const env = localEnvironment(meta.env || {});
const executable = env.VERCEL_PHP_EXECUTABLE || 'php';
const router = path.resolve(workPath, entrypoint);
const docroot = path.resolve(workPath, env.VERCEL_PHP_DOCROOT || publicDir || '.');
if (!(await fs.stat(router)).isFile()) throw new Error(`PHP entrypoint is not a file: ${router}`);
if (!(await fs.stat(docroot)).isDirectory()) throw new Error(`PHP document root is not a directory: ${docroot}`);

const ini = path.join(workPath, 'api', 'php.ini');
try {
if ((await fs.stat(ini)).isFile()) {
// Keep the host's primary php.ini/extensions and scan project overrides
// afterward, rather than loading Linux runtime configuration locally.
env.PHP_INI_SCAN_DIR = `${env.PHP_INI_SCAN_DIR || ''}${path.delimiter}${path.dirname(ini)}`;
}
} catch (error) {
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
}

for (let attempt = 0; attempt < 3; attempt++) {
const port = await availablePort();
const child = spawn(executable, ['-S', `127.0.0.1:${port}`, '-t', docroot, router], {
cwd: workPath, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
let shutdownPromise: Promise<void> | undefined;
const shutdown = () => {
process.removeListener('exit', onExit);
if (!shutdownPromise) shutdownPromise = stop(child);
return shutdownPromise;
};
// Do not accumulate per-request exit handlers after CLI shutdown.
const onExit = () => { child.kill(); };
process.once('exit', onExit);
child.once('close', () => process.removeListener('exit', onExit));
child.stdout?.on('data', onStdout || (data => process.stdout.write(data)));
child.stderr?.on('data', onStderr || (data => process.stderr.write(data)));
try {
await ready(child, port);
if (!child.pid) throw new Error('Local PHP server did not provide a process ID.');
return { port, pid: child.pid, shutdown };
} catch (error) {
await shutdown();
if (attempt < 2 && error instanceof Error && /Failed to listen/.test(error.message)) continue;
throw error;
}
}
throw new Error('Unable to allocate a local PHP server port.');
};
11 changes: 3 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,9 @@ export const build: BuildV3 = async ({
config = {},
meta = {},
}) => {
// Check if now dev mode is used
// Local HTTP invocations use startDevServer, never the Linux Lambda bundle.
if (meta.isDev) {
console.log(`
🐘 vercel dev is not supported right now.
Please use PHP built-in development server.

php -S localhost:8000 api/index.php
`);
process.exit(255);
throw new Error('Local PHP requests must use the startDevServer hook. Update your Vercel CLI.');
}

console.log('🐘 Downloading user files');
Expand Down Expand Up @@ -153,3 +147,4 @@ export const prepareCache: PrepareCache = async ({ workPath }) => {
};

export { shouldServe };
export { startDevServer } from './dev-server';
33 changes: 33 additions & 0 deletions test/integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Local development tests

Build the runtime before running tests:

```sh
npm run build
npx jest --runInBand test/spec/index.dev.js
```

The PHP tests use `php` on `PATH`, or `TEST_PHP_EXECUTABLE` if set. Tests that need PHP are explicitly skipped with a warning if the executable cannot run `-n -v`. Missing-executable and BuildV3 export/fallback tests always run. Set `PHPRC` when your host installation needs an isolated configuration. Tests create and remove fixtures under the ignored `.local` directory.

## Real Vercel CLI dispatch

Install Vercel CLI separately in an ignored tool directory, or use an existing installation. For example:

```sh
npm install --prefix .local/cli --ignore-scripts vercel@50.4.5
TEST_VERCEL_CLI="$PWD/.local/cli/node_modules/vercel/dist/vc.js" node test/integration/vercel-dev.js
```

In PowerShell:

```powershell
$env:TEST_VERCEL_CLI = "$PWD/.local/cli/node_modules/vercel/dist/vc.js"
$env:TEST_PHP_EXECUTABLE = 'C:\php\php.exe'
node test/integration/vercel-dev.js
```

Without `TEST_VERCEL_CLI` or runnable PHP, this script prints an explicit skip message. It invokes the unmodified CLI against generated projects using both legacy `builds` and current `functions` configurations, with this checkout's compiled runtime in the project's local builder cache. It checks direct and nested entrypoints, rewrites and query merging, form/binary bodies, concurrent endpoints, static files, `.env`, project ini overrides and the CLI's per-response PHP shutdown.

No account or deployment is used. Global config is isolated inside each fixture; the command uses a dummy token and the CLI's `__VERCEL_SKIP_DEV_CMD` test escape hatch to avoid project linking. The API URL is a loopback trap, and the test asserts that no API calls occurred. It never changes real credentials. Tool installs and generated fixtures are not part of the package or PR.

The hook follows the [Vercel CLI 50.4.5 dispatch contract](https://github.com/vercel/vercel/blob/vercel%4050.4.5/packages/cli/src/util/dev/server.ts): a hook call per matched request, original URL plus routing query parameters proxied to the returned port, and `shutdown` on response close. It does not cache a persistent PHP process across invocations.
Loading