Development
This page covers local development workflow: starting a dev server with hot PHP reload,
file watching with entr, and running tests.
Quick start
# Install dependencies
composer install
cd website && pnpm install && cd ..
# Start server + file watcher + CSS watcher (all-in-one)
composer run dev
Prerequisites
Beyond PHP + OpenSwoole + Composer, the dev workflow needs two extra tools:
-
entr— file watcher that triggers a command when watched files change.
Ubuntu/Debian:sudo apt install entr· macOS:brew install entr -
pnpm— runs the PostCSS CSS watcher for the website. Only required for the website; skip if you're working on a project without a CSS pipeline. Install from pnpm.io.
scripts/dev.sh checks for entr at startup and exits with install instructions if it's missing. pnpm is optional — CSS watch is silently skipped if it's absent.
What composer run dev does
It starts three concurrent processes:
- PHP server —
APP_ENV=dev php website/app.phpon:3000 - CSS watcher —
pnpm dev(PostCSS--watch, website only) - PHP file watcher —
find … | entr -dn kill -USR1 $MASTER_PID
Ctrl+C kills all three cleanly.
APP_ENV=dev enables:
- Debug-level logging
- Self-signed TLS certificate loading (if
certs/dev.crtandcerts/dev.keyexist — see Deployment → Dev mode) - CORS logger output
How PHP hot reload works
php-via runs in POOL_MODE (OpenSwoole's process model): a long-lived master process manages
a manager and one or more worker processes. When entr detects a .php file change, it
sends SIGUSR1 to the master. The master signals the manager, which gracefully rotates the worker:
- The current worker finishes its in-flight requests.
- A new worker process is forked from the master.
- The new worker runs
onWorkerStart, which executes theonStartcallbacks. - The
onStartcallbackrequiresroutes.php— loading fresh class definitions from disk. - The route table is refreshed so
RequestHandlersees the new routes.
The thin bootstrap pattern
Hot reload only picks up fresh class definitions for files that were not already loaded in the master process. If a class is loaded during master startup, all forked workers inherit that old definition — USR1 makes no difference.
The solution is a thin app.php: the entry script only sets up configuration,
middleware, and routes that don't reference app-layer classes. Everything else — example classes, route handlers,
and the sitemap — lives in routes.php, which is loaded via onStart() and therefore
runs inside the worker, not master:
// app.php — thin bootstrap (master process)
$app = new Via($config);
$app->middleware(/* ... */);
// Docs routes (closures only, no app-layer class references)
$app->group('/docs', function (Via $app): void { /* ... */ });
// Defer example route registration to worker startup
$app->onStart(function () use ($app): void {
require __DIR__ . '/routes.php'; // ← loaded fresh per worker
});
$app->start();
// routes.php — loaded inside each worker (hot-reloadable)
use MyApp\Examples\CounterExample;
use MyApp\Examples\ChatExample;
CounterExample::register($app);
ChatExample::register($app);
What gets reloaded vs. what doesn't
| Change | Reload needed? | How |
|---|---|---|
Files in routes.php and its used classes | USR1 (entr automatic) | Fresh worker re-includes routes.php |
Twig templates (.twig) | USR1 (entr automatic) | Fresh worker clears in-memory compiled-template cache and ViewCache |
CSS (.css.src) | None — already live | pnpm dev watches and rebuilds |
Changes to app.php itself | Full restart | Ctrl+C → composer run dev |
Framework files in src/ | Full restart | Framework classes load in master |
OPcache note
For hot reload to pick up changed files, OPcache must revalidate timestamps.
In most dev setups this is the default (opcache.validate_timestamps=1).
If files aren't updating after reload, check your php.ini:
; php.ini (dev)
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0 ; check every request (0 = always)
Twig template changes
Twig compiles each template into a PHP class and stores it in memory for the worker's lifetime.
Editing a .twig file triggers entr → USR1 → a fresh worker, which starts
with an empty template cache and also clears ViewCache (important for ROUTE/SESSION/GLOBAL scoped pages
that cache rendered HTML in memory). No browser action needed beyond the automatic reload.
CSS changes
composer run dev starts pnpm dev alongside the server. This runs PostCSS with
--watch, rebuilding site.css whenever the source changes.
For a one-off rebuild: composer run css.
Running tests
# Run the full suite once
composer run test
# Watch mode: re-run on every .php file change (requires entr)
composer run watch-test
Tests use VIA_TEST_MODE=1 (set in tests/Pest.php), which prevents
OpenSwoole from binding a port — no running server needed.
Using this pattern in your project
Copy the thin bootstrap pattern into your own app. The key rules:
app.phpmust notuseor instantiate any class you want to hot-reload.- Put all hot-reloadable route registrations in a separate file (e.g.
routes.php) and load it viaonStart(). - Any code that runs before
$app->onStart()inapp.phpis fixed for the lifetime of the master process.
A minimal scripts/dev.sh for your project:
#!/usr/bin/env bash
# Requires entr: apt install entr / brew install entr
APP_ENV=dev php app.php &
SERVER_PID=$!
cleanup() { kill "$SERVER_PID" 2>/dev/null; exit 0; }
trap cleanup INT TERM
while kill -0 "$SERVER_PID" 2>/dev/null; do
find . \( -name '*.php' -o -name '*.twig' \) | grep -v /vendor/ | \
entr -dn sh -c "kill -USR1 $SERVER_PID && echo '↻ Workers reloading...'" || true
done
And in your composer.json:
{
"scripts": {
"dev": "bash scripts/dev.sh",
"test": "vendor/bin/pest",
"watch-test": "find src tests -name '*.php' | entr -c vendor/bin/pest"
}
}
Next steps
- Deployment — systemd, Caddy, TLS, graceful restart in production
- Lifecycle —
onStart,onShutdown, timers