Initial public release

This commit is contained in:
2026-06-16 18:55:05 +03:30
commit 17dfd5e49d
10 changed files with 787 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.php]
indent_size = 4

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Local machine configuration. Copy config.example.json to config.json.
/config.json
# Runtime/test output.
/.php-dev-router.*
/tmp/
# Dependency and editor state.
/vendor/
/.idea/
/.vscode/
.DS_Store

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 php-dev-router contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# php-dev-router
Local HTTPS router for PHP and Laravel projects.
`php-dev-router` discovers PHP apps in a projects directory, generates nginx
virtual hosts, adds exact local host records to `/etc/hosts`, and can run as a
small systemd watcher so newly added projects become available automatically.
It is built for development workstations, not production servers.
## Features
- Detects Laravel apps, public front-controller apps, and generic PHP apps.
- Generates HTTPS nginx server blocks backed by PHP-FPM.
- Maps only detected project hostnames to `127.0.0.1`.
- Avoids wildcard DNS hijacking, so unrelated real domains keep resolving
normally.
- Supports nested apps such as `monorepo/api`.
- Has no runtime Composer dependencies.
## Requirements
- Linux with systemd
- PHP 8.2 or newer
- nginx
- PHP-FPM
- A local or trusted development certificate/key for the configured domain
The default example config uses `php.test`. Browsers will still require a
certificate trusted by your machine for whatever domain you configure.
## Quick Start
```bash
git clone <repo-url>
cd php-dev-router
cp config.example.json config.json
$EDITOR config.json
./bin/php-dev-router scan
./bin/php-dev-router doctor
sudo ./bin/php-dev-router apply
sudo ./bin/php-dev-router install
```
`install.sh` performs the same install flow and creates `config.json` from the
example if it does not already exist:
```bash
./install.sh
```
## Configuration
Local settings live in `config.json`, which is intentionally ignored by git.
Start from `config.example.json`.
Important options:
- `projects_root`: directory that contains your PHP projects.
- `domain`: suffix used for generated hostnames.
- `ssl_certificate` / `ssl_certificate_key`: certificate used by nginx.
- `php_fpm_socket`: PHP-FPM socket passed to nginx.
- `nginx_conf`: generated nginx config target.
- `hosts_file`: hosts file target, usually `/etc/hosts`.
- `systemd_service`: watcher service target.
Paths may use `~`.
## Commands
```bash
./bin/php-dev-router scan [--json]
./bin/php-dev-router doctor
./bin/php-dev-router apply [--dry-run] [--no-reload] [--no-nginx-test]
./bin/php-dev-router watch [--interval=3]
./bin/php-dev-router install [--dry-run]
```
Use `apply --dry-run` to review generated nginx and hosts output before writing
system files.
`--no-nginx-test` is intended only for temporary non-root tests. Normal system
installs should let the tool run `nginx -t` before reload.
## Detection Rules
- Laravel app: `artisan` and `public/index.php`, served from `public`.
- Public front-controller app: `public/index.php`, served from `public`.
- Generic PHP app: `index.php`, served from that directory.
Ignored directories include hidden/tooling directories, `vendor`, `node_modules`,
`storage`, and common IDE/cache paths.
## Hostnames
Hostnames are built from the relative project path and the configured domain.
Path parts are joined with hyphens.
Examples for `domain = "php.test"`:
- `billing_admin` -> `billing-admin.php.test`
- `platform/api` -> `platform-api.php.test`
If two projects collide after slugging, a short deterministic hash is appended.
## What Install Writes
`install` writes:
- the generated nginx config from `nginx_conf`
- a managed `php-dev-router` block in `hosts_file`
- the watcher service from `systemd_service`
It then runs `nginx -t`, reloads nginx, enables PHP-FPM/nginx when their systemd
units are available, and enables `php-dev-router.service`.
## Publishing Notes
Do not commit `config.json`; it contains machine-specific paths, domains, and
certificate locations. Commit `config.example.json` instead.

14
SECURITY.md Normal file
View File

@@ -0,0 +1,14 @@
# Security Policy
`php-dev-router` is intended for local development machines. Do not use it as a
public production vhost manager.
The tool writes nginx, hosts, and systemd files only when explicitly run with
sufficient permissions. Review generated config with:
```bash
./bin/php-dev-router apply --dry-run
```
Please report security issues privately to the repository maintainer instead of
opening a public issue with exploit details.

537
bin/php-dev-router Executable file
View File

@@ -0,0 +1,537 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
const VERSION = '1.0.0';
const HOSTS_BEGIN = '# BEGIN php-dev-router';
const HOSTS_END = '# END php-dev-router';
main($argv);
function main(array $argv): void
{
$command = $argv[1] ?? 'help';
$options = parseOptions(array_slice($argv, 2));
$config = loadConfig($options['config'] ?? null);
try {
match ($command) {
'scan' => commandScan($config, $options),
'apply' => commandApply($config, $options),
'watch' => commandWatch($config, $options),
'doctor', 'status' => commandDoctor($config),
'install' => commandInstall($config, $options),
'help', '-h', '--help' => commandHelp(),
default => fail("Unknown command: {$command}\nRun: php-dev-router help"),
};
} catch (RuntimeException $exception) {
fwrite(STDERR, "php-dev-router: {$exception->getMessage()}\n");
exit(1);
}
}
function parseOptions(array $args): array
{
$options = [];
foreach ($args as $arg) {
if (!str_starts_with($arg, '--')) {
$options['_'][] = $arg;
continue;
}
$arg = substr($arg, 2);
if (str_contains($arg, '=')) {
[$key, $value] = explode('=', $arg, 2);
$options[$key] = $value;
} else {
$options[$arg] = true;
}
}
return $options;
}
function loadConfig(?string $path): array
{
$projectRoot = dirname(__DIR__);
$configPath = $path ? expandPath($path) : defaultConfigPath($projectRoot);
if (!is_file($configPath)) {
fail("Config file not found: {$configPath}");
}
$config = json_decode((string) file_get_contents($configPath), true);
if (!is_array($config)) {
fail("Config file is not valid JSON: {$configPath}");
}
$defaults = [
'projects_root' => getenv('HOME') . '/PhpstormProjects',
'domain' => 'php.test',
'ssl_certificate' => getenv('HOME') . '/.ssl/php-dev-router/fullchain.pem',
'ssl_certificate_key' => getenv('HOME') . '/.ssl/php-dev-router/privkey.pem',
'php_fpm_socket' => '/run/php-fpm/php-fpm.sock',
'nginx_conf' => '/etc/nginx/conf.d/php-dev-router.conf',
'hosts_file' => '/etc/hosts',
'systemd_service' => '/etc/systemd/system/php-dev-router.service',
'skip_dirs' => ['.git', '.idea', '.vscode', 'bootstrap/cache', 'node_modules', 'storage', 'vendor'],
'scan_max_depth' => 5,
'watch_interval_seconds' => 3,
'config_path' => $configPath,
'project_root' => $projectRoot,
];
$config = array_replace($defaults, $config);
foreach (['projects_root', 'ssl_certificate', 'ssl_certificate_key', 'nginx_conf', 'hosts_file', 'systemd_service'] as $key) {
$config[$key] = expandPath((string) $config[$key]);
}
$config['domain'] = trim((string) $config['domain'], ". \t\n\r\0\x0B");
$config['scan_max_depth'] = max(1, (int) $config['scan_max_depth']);
$config['watch_interval_seconds'] = max(1, (int) $config['watch_interval_seconds']);
return $config;
}
function commandHelp(): void
{
$version = VERSION;
echo <<<TEXT
php-dev-router v{$version}
Usage:
php-dev-router scan [--json]
php-dev-router apply [--dry-run] [--no-reload] [--no-nginx-test]
php-dev-router watch [--interval=3]
php-dev-router doctor
php-dev-router install [--dry-run]
Commands:
scan Discover PHP/Laravel apps and show hostnames.
apply Generate nginx config and exact local hosts entries.
watch Re-apply when the project inventory changes.
doctor Check config, certs, services, generated files, and DNS.
install Create the systemd service and run apply.
TEXT;
}
function commandScan(array $config, array $options): void
{
$apps = discoverApps($config);
if (isset($options['json'])) {
echo json_encode($apps, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return;
}
if ($apps === []) {
echo "No serveable PHP apps found in {$config['projects_root']}\n";
return;
}
foreach ($apps as $app) {
echo "{$app['host']} -> {$app['root']} ({$app['type']})\n";
}
}
function commandApply(array $config, array $options): void
{
$apps = discoverApps($config);
$nginx = renderNginx($config, $apps);
$hosts = renderHostsBlock($apps);
if (isset($options['dry-run'])) {
echo "Would write nginx config: {$config['nginx_conf']}\n";
echo "Would update hosts file: {$config['hosts_file']}\n\n";
echo $nginx;
echo "\n";
echo $hosts;
return;
}
ensureWritableTargets([$config['nginx_conf'], $config['hosts_file']]);
atomicWrite($config['nginx_conf'], $nginx);
updateHostsFile($config['hosts_file'], $hosts);
if (empty($options['no-nginx-test'])) {
runOrFail(['nginx', '-t']);
}
if (empty($options['no-reload'])) {
reloadService('nginx');
}
echo "Applied " . count($apps) . " app route(s).\n";
}
function commandWatch(array $config, array $options): void
{
$interval = isset($options['interval']) ? max(1, (int) $options['interval']) : $config['watch_interval_seconds'];
echo "Watching {$config['projects_root']} every {$interval}s\n";
$lastHash = '';
while (true) {
$apps = discoverApps($config);
$hash = hash('sha256', json_encode($apps, JSON_UNESCAPED_SLASHES));
if ($hash !== $lastHash) {
$lastHash = $hash;
try {
commandApply($config, ['no-reload' => false]);
} catch (RuntimeException $exception) {
fwrite(STDERR, date('c') . " apply failed: {$exception->getMessage()}\n");
}
}
sleep($interval);
}
}
function commandDoctor(array $config): void
{
$apps = discoverApps($config);
$checks = [
['Projects root', is_dir($config['projects_root']), $config['projects_root']],
['SSL cert', is_file($config['ssl_certificate']), $config['ssl_certificate']],
['SSL key', is_file($config['ssl_certificate_key']), $config['ssl_certificate_key']],
['PHP-FPM socket', file_exists($config['php_fpm_socket']), $config['php_fpm_socket']],
['nginx config dir', is_dir(dirname($config['nginx_conf'])), dirname($config['nginx_conf'])],
['hosts file', is_file($config['hosts_file']), $config['hosts_file']],
];
foreach ($checks as [$name, $ok, $detail]) {
echo sprintf("%-18s %s %s\n", $name, $ok ? 'OK ' : 'MISS', $detail);
}
echo "\nDetected apps: " . count($apps) . "\n";
foreach ($apps as $app) {
$resolved = trim((string) shell_exec('getent hosts ' . escapeshellarg($app['host']) . ' 2>/dev/null'));
$dnsStatus = str_contains($resolved, '127.0.0.1') ? 'resolves' : 'not-local';
echo " {$app['host']} -> {$app['root']} ({$dnsStatus})\n";
}
}
function commandInstall(array $config, array $options): void
{
$service = renderSystemdService($config);
if (isset($options['dry-run'])) {
echo "Would write systemd service: {$config['systemd_service']}\n\n";
echo $service;
echo "\n";
commandApply($config, ['dry-run' => true]);
return;
}
ensureWritableTargets([$config['systemd_service'], $config['nginx_conf'], $config['hosts_file']]);
atomicWrite($config['systemd_service'], $service);
commandApply($config, ['no-reload' => false]);
runOrFail(['systemctl', 'daemon-reload']);
enableServiceIfAvailable('php-fpm');
enableServiceIfAvailable('nginx');
runOrFail(['systemctl', 'enable', '--now', 'php-dev-router.service']);
echo "Installed php-dev-router watcher service.\n";
}
function discoverApps(array $config): array
{
$root = rtrim($config['projects_root'], '/');
if (!is_dir($root)) {
return [];
}
$candidates = [];
scanDirectory($root, $root, 0, $config, $candidates);
$usedHosts = [];
$apps = [];
foreach ($candidates as $candidate) {
$relative = trim(substr($candidate['app_dir'], strlen($root)), '/');
$slug = slugify($relative === '' ? basename($candidate['app_dir']) : $relative);
$host = "{$slug}.{$config['domain']}";
if (isset($usedHosts[$host])) {
$hash = substr(hash('sha1', $candidate['app_dir']), 0, 7);
$host = "{$slug}-{$hash}.{$config['domain']}";
}
$usedHosts[$host] = true;
$apps[] = [
'host' => $host,
'name' => $slug,
'type' => $candidate['type'],
'app_dir' => $candidate['app_dir'],
'root' => $candidate['root'],
'index' => $candidate['root'] . '/index.php',
];
}
usort($apps, fn (array $a, array $b): int => strcmp($a['host'], $b['host']));
return $apps;
}
function scanDirectory(string $dir, string $base, int $depth, array $config, array &$candidates): void
{
if ($depth > $config['scan_max_depth']) {
return;
}
$dir = rtrim($dir, '/');
if (is_file("{$dir}/public/index.php")) {
$type = is_file("{$dir}/artisan") ? 'laravel' : 'public-front-controller';
$candidates[] = ['app_dir' => $dir, 'root' => "{$dir}/public", 'type' => $type];
} elseif (is_file("{$dir}/index.php")) {
$candidates[] = ['app_dir' => $dir, 'root' => $dir, 'type' => 'generic-php'];
}
$children = @scandir($dir);
if ($children === false) {
return;
}
foreach ($children as $child) {
if ($child === '.' || $child === '..') {
continue;
}
$path = "{$dir}/{$child}";
if ($child === 'public' || str_starts_with($child, '.')) {
continue;
}
if (!is_dir($path) || shouldSkip($path, $base, $config['skip_dirs'])) {
continue;
}
scanDirectory($path, $base, $depth + 1, $config, $candidates);
}
}
function shouldSkip(string $path, string $base, array $skipDirs): bool
{
$relative = trim(substr($path, strlen(rtrim($base, '/'))), '/');
$name = basename($path);
foreach ($skipDirs as $skip) {
$skip = trim((string) $skip, '/');
if ($skip === $name || $skip === $relative || str_ends_with($relative, "/{$skip}")) {
return true;
}
}
return false;
}
function renderNginx(array $config, array $apps): string
{
$lines = [
'# Managed by php-dev-router. Re-running apply replaces this file.',
'# Source: ' . $config['config_path'],
'',
];
foreach ($apps as $app) {
$host = nginxEscape($app['host']);
$root = nginxEscape($app['root']);
$cert = nginxEscape($config['ssl_certificate']);
$key = nginxEscape($config['ssl_certificate_key']);
$socket = nginxEscape($config['php_fpm_socket']);
$lines[] = "server {";
$lines[] = " listen 80;";
$lines[] = " server_name {$host};";
$lines[] = " return 301 https://{$host}\$request_uri;";
$lines[] = "}";
$lines[] = "";
$lines[] = "server {";
$lines[] = " listen 443 ssl;";
$lines[] = " server_name {$host};";
$lines[] = "";
$lines[] = " ssl_certificate \"{$cert}\";";
$lines[] = " ssl_certificate_key \"{$key}\";";
$lines[] = "";
$lines[] = " root \"{$root}\";";
$lines[] = " index index.php index.html;";
$lines[] = "";
$lines[] = " client_max_body_size 128m;";
$lines[] = "";
$lines[] = " location / {";
$lines[] = " try_files \$uri \$uri/ /index.php?\$query_string;";
$lines[] = " }";
$lines[] = "";
$lines[] = " location ~ \\.php$ {";
$lines[] = " try_files \$uri =404;";
$lines[] = " fastcgi_pass unix:{$socket};";
$lines[] = " fastcgi_index index.php;";
$lines[] = " include fastcgi.conf;";
$lines[] = " }";
$lines[] = "";
$lines[] = " location ~ /\\.(?!well-known).* {";
$lines[] = " deny all;";
$lines[] = " }";
$lines[] = "}";
$lines[] = "";
}
return implode("\n", $lines);
}
function renderHostsBlock(array $apps): string
{
$lines = [HOSTS_BEGIN];
foreach ($apps as $app) {
$lines[] = "127.0.0.1 {$app['host']}";
}
$lines[] = HOSTS_END;
$lines[] = '';
return implode("\n", $lines);
}
function updateHostsFile(string $path, string $block): void
{
$current = is_file($path) ? (string) file_get_contents($path) : '';
$pattern = '/' . preg_quote(HOSTS_BEGIN, '/') . '.*?' . preg_quote(HOSTS_END, '/') . "\\n?/s";
if (preg_match($pattern, $current)) {
$new = preg_replace($pattern, $block, $current);
} else {
$new = rtrim($current) . "\n\n" . $block;
}
atomicWrite($path, (string) $new);
}
function renderSystemdService(array $config): string
{
$php = trim((string) shell_exec('command -v php 2>/dev/null')) ?: '/usr/bin/php';
$script = __FILE__;
$configPath = $config['config_path'];
$interval = $config['watch_interval_seconds'];
return <<<UNIT
[Unit]
Description=PHP development router
After=network.target nginx.service php-fpm.service
Wants=nginx.service php-fpm.service
[Service]
Type=simple
ExecStart={$php} {$script} watch --config={$configPath} --interval={$interval}
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
UNIT;
}
function defaultConfigPath(string $projectRoot): string
{
$localConfig = "{$projectRoot}/config.json";
if (is_file($localConfig)) {
return $localConfig;
}
return "{$projectRoot}/config.example.json";
}
function atomicWrite(string $path, string $contents): void
{
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
fail("Could not create directory: {$dir}");
}
$mode = file_exists($path) ? (fileperms($path) & 0777) : 0644;
$owner = file_exists($path) ? fileowner($path) : false;
$group = file_exists($path) ? filegroup($path) : false;
$tmp = tempnam($dir, '.php-dev-router.');
if ($tmp === false) {
fail("Could not create temp file in {$dir}");
}
if (file_put_contents($tmp, $contents) === false) {
@unlink($tmp);
fail("Could not write temp file: {$tmp}");
}
@chmod($tmp, $mode);
if ($owner !== false) {
@chown($tmp, $owner);
}
if ($group !== false) {
@chgrp($tmp, $group);
}
if (!rename($tmp, $path)) {
@unlink($tmp);
fail("Could not replace file: {$path}");
}
@chmod($path, $mode);
}
function runOrFail(array $command): void
{
$escaped = array_map('escapeshellarg', $command);
$cmd = implode(' ', $escaped) . ' 2>&1';
exec($cmd, $output, $code);
if ($code !== 0) {
fail("Command failed ({$code}): " . implode(' ', $command) . "\n" . implode("\n", $output));
}
}
function reloadService(string $service): void
{
exec('systemctl reload ' . escapeshellarg($service) . ' 2>&1', $output, $code);
if ($code === 0) {
return;
}
exec('systemctl restart ' . escapeshellarg($service) . ' 2>&1', $restartOutput, $restartCode);
if ($restartCode !== 0) {
fail("Could not reload or restart {$service}\n" . implode("\n", array_merge($output, $restartOutput)));
}
}
function enableServiceIfAvailable(string $service): void
{
exec('systemctl list-unit-files ' . escapeshellarg($service . '.service') . ' --no-legend 2>/dev/null', $output, $code);
if ($code === 0 && trim(implode("\n", $output)) !== '') {
runOrFail(['systemctl', 'enable', '--now', $service . '.service']);
}
}
function ensureWritableTargets(array $paths): void
{
$blocked = [];
foreach ($paths as $path) {
$parent = dirname($path);
if ((is_file($path) && is_writable($path)) || (!file_exists($path) && is_dir($parent) && is_writable($parent))) {
continue;
}
$blocked[] = $path;
}
if ($blocked !== []) {
fail("This command cannot write required target(s). Re-run with sudo.\nTargets: " . implode(', ', $blocked));
}
}
function expandPath(string $path): string
{
if ($path === '~') {
return (string) getenv('HOME');
}
if (str_starts_with($path, '~/')) {
return getenv('HOME') . substr($path, 1);
}
return $path;
}
function slugify(string $value): string
{
$value = strtolower($value);
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value;
$value = trim($value, '-');
return $value !== '' ? $value : 'app';
}
function nginxEscape(string $value): string
{
return str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
}
function fail(string $message): never
{
throw new RuntimeException($message);
}

24
composer.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "php-dev-router/php-dev-router",
"description": "Local HTTPS nginx router for PHP and Laravel development projects.",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.2"
},
"bin": [
"bin/php-dev-router"
],
"scripts": {
"lint": "php -l bin/php-dev-router",
"scan": "bin/php-dev-router scan"
},
"keywords": [
"php",
"laravel",
"nginx",
"php-fpm",
"development",
"https"
]
}

22
config.example.json Normal file
View File

@@ -0,0 +1,22 @@
{
"projects_root": "~/PhpstormProjects",
"domain": "php.test",
"ssl_certificate": "~/.ssl/php-dev-router/fullchain.pem",
"ssl_certificate_key": "~/.ssl/php-dev-router/privkey.pem",
"php_fpm_socket": "/run/php-fpm/php-fpm.sock",
"nginx_conf": "/etc/nginx/conf.d/php-dev-router.conf",
"hosts_file": "/etc/hosts",
"systemd_service": "/etc/systemd/system/php-dev-router.service",
"skip_dirs": [
".cd",
".git",
".idea",
".vscode",
"bootstrap/cache",
"node_modules",
"storage",
"vendor"
],
"scan_max_depth": 5,
"watch_interval_seconds": 3
}

11
install.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [[ ! -f config.json ]]; then
cp config.example.json config.json
echo "Created config.json from config.example.json. Edit it if this machine needs different paths."
fi
sudo -v
sudo ./bin/php-dev-router install
./bin/php-dev-router doctor

View File

@@ -0,0 +1,13 @@
[Unit]
Description=PHP development router
After=network.target nginx.service php-fpm.service
Wants=nginx.service php-fpm.service
[Service]
Type=simple
ExecStart=/usr/bin/php /opt/php-dev-router/bin/php-dev-router watch --config=/etc/php-dev-router/config.json --interval=3
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target