Documentation menu

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:

  1. PHP serverAPP_ENV=dev php website/app.php on :3000
  2. CSS watcherpnpm dev (PostCSS --watch, website only)
  3. PHP file watcherfind … | 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.crt and certs/dev.key exist — 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:

  1. The current worker finishes its in-flight requests.
  2. A new worker process is forked from the master.
  3. The new worker runs onWorkerStart, which executes the onStart callbacks.
  4. The onStart callback requires routes.php — loading fresh class definitions from disk.
  5. The route table is refreshed so RequestHandler sees 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

ChangeReload needed?How
Files in routes.php and its used classesUSR1 (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 livepnpm dev watches and rebuilds
Changes to app.php itselfFull restartCtrl+C → composer run dev
Framework files in src/Full restartFramework 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.php must not use or 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 via onStart().
  • Any code that runs before $app->onStart() in app.php is 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
  • LifecycleonStart, onShutdown, timers