Documentation menu

Development

This page covers the local development workflow: starting a dev server with hot PHP reload, file watching with entr, what dev mode changes, 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 server: APP_ENV=dev php website/app.php on :3000
  2. CSS watcher: pnpm dev (PostCSS --watch, website only)
  3. PHP file watcher: find … | entr -dn kill -USR1 $MASTER_PID

Ctrl+C kills all three cleanly.

APP_ENV=dev makes the website's app.php call withDevMode(true) and 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

What dev mode changes

Config::withDevMode(true) trades speed and safety for feedback:

  • A page that throws shows the exception's class and message instead of "Internal Server Error". A failing action still answers "Action failed" and logs the exception.
  • The Dev Bar is on, its signal editor can be turned on with withDevBarOptions(writes: true), and /_stats answers.
  • The default shell shows its Live Signals panel.
  • Shell templates are read again after an edit, and static files are checked on every request, so an edit shows on the next reload. Static responses carry Cache-Control: no-cache.
  • The server opens its port without compressing static files first.
  • Action requests without an Origin header are accepted, so curl works without withAllowMissingOrigin().
  • php-via warns once about mistakes that are quiet in production: a browser value of the wrong type for a signal, a view whose tabs all rendered the same HTML without shareRender, a page with via_head and no Datastar script, a second import map.
  • The master process writes its PID to php-via-master.pid in the system's temp directory, which scripts/dev.sh reads.

Turn it off in production: withDevMode(getenv('APP_ENV') === 'dev').

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 the onWorkerStart() callbacks.
  4. The onWorkerStart() 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 onWorkerStart() 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->onWorkerStart(function (int $workerId) 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
Shell templates (withShellTemplate())None in dev mode, USR1 otherwiseRead again after an edit in dev mode, once per worker otherwise
Files in withStaticDir()None in dev mode, USR1 otherwiseChecked on every request in dev mode, kept in worker memory otherwise
CSS (.css.src)None (already live)pnpm 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 templates need twig/twig (composer require twig/twig); php-via no longer installs it. 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 an empty ViewCache, which holds the shared renders of views with shareRender: true. 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

php-via's own suite sets VIA_TEST_MODE=1 in tests/Pest.php, which keeps start() from binding a port. Tests that start a real server take their port from a window per fixture, or from one window for all of them:

VIA_TEST_PORT_BASE=4350 VIA_TEST_PORT_COUNT=40 composer run test

An app tests its pages with Testing\TestApp, which runs them through php-via's own request, action and SSE handlers in the test's process, with no server, no port and no VIA_TEST_MODE:

use Mbolli\PhpVia\Config;
use Mbolli\PhpVia\Context;
use Mbolli\PhpVia\Testing\TestApp;
use Mbolli\PhpVia\Via;

it('counts clicks', function (): void {
    $app = new TestApp(new Config(), function (Via $via): void {
        $via->page('/counter', function (Context $c): void {
            $count = $c->signal(0, 'count');
            $c->action(fn () => $count->setValue($count->int() + 1), 'increment');
            $c->view(fn (): string => "<main id=\"counter\">{$count->int()}</main>");
        });
    });
    $tab = $app->open('/counter');
    $tab->action('increment');

    expect($tab->signal('count'))->toBe(1);
    $app->shutdown();
});

patches() returns what the stream sent since the last call, html() renders the page from server state, disconnect(expire: true) and connect() test a revival, and runTasks() runs spawn() tasks, and timers fire while it does: those of $c->setInterval() and of $app->setInterval(), which TestApp arms as worker 0 does. With no task or broadcast pending it returns at once and no timer fires, so a test that needs an interval's work calls it directly. TestApp runs one worker and renders broadcasts at once, without the broadcast tick.

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 onWorkerStart().
  • Any code that runs before $app->onWorkerStart() in app.php is fixed for the lifetime of the master process.
  • Make every Config call before new Via($config), which freezes the Config.

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