Releases: mbolli/php-via
v0.8.0 (2026-05-04)
Breaking Changes
Config::withTrustProxy()removed: the dynamic per-request base-path detection
mechanism (detectBasePathFromRequest(),withTrustProxy(),getTrustProxy()) has been
removed entirely. The base path must now be known at startup and set via
Config::withBasePath(string $basePath), which throws\InvalidArgumentExceptionfor
invalid values (absolute URLs, protocol-relative paths, backslashes, etc.).
Migration: remove any->withTrustProxy(true)call; if your app is mounted at a sub-path,
set it explicitly:->withBasePath('/myapp').
New Features
-
Multi-worker support: php-via can now run with multiple OpenSwoole workers sharing a
single port, enabling CPU parallelism on multi-core hosts.Config::withWorkerNum(int $n): set the number of worker processes (default 1). Requires
a multi-worker-capable broker.Config::withGlobalStateTableSize(int $maxRows, int $maxValueBytes): tune the shared
memory table used forGlobalStatein multi-worker mode (defaults: 1024 rows × 4096 B).SwooleBroker: new broker that uses OpenSwoole's inter-worker IPC pipe to fan-out
scope invalidations across all worker processes on the same machine. No external
infrastructure required.InMemoryBrokeris rejected at startup whenworker_num > 1.SharedTable: wrapsOpenSwoole\Table(shared memory, mmap'd into all workers on
fork) as theGlobalStatebackend whenworker_num > 1. Values are PHP-serialized;
throws\OverflowExceptionif a value exceeds the configured column size.- Session-affinity dispatch: when
worker_num > 1, a customdispatch_funcroutes
every request from the same browser session (page load, SSE, action POSTs) to the same
worker viaSessionManager::workerForRequest(). No external load balancer required; all
Contextlookups andsessionData()calls always hit the correct process.
-
POOL_MODE + USR1 graceful worker reload: the server now runs in
POOL_MODE.
Sending SIGUSR1 to the master process triggers graceful worker rotation without dropping
active connections; the fresh worker re-includes route definitions, picking up new class
definitions from disk. The master PID is written tosys_get_temp_dir()/php-via-master.pid
in dev mode. -
composer run dev/scripts/dev.shhot-reload workflow: anentr-based file
watcher that sends SIGUSR1 on every.phpor.twigchange, plus an optional pnpm CSS
watcher for the website. Runcomposer run devfrom the project root; requiresentr
(apt install entr/brew install entr). -
File upload + SharedWorker Upload demos: two new website examples: a streaming
file-upload progress example and a SharedWorker-based upload demo that maintains upload
state across multiple tabs. -
Auto-inject signals and actions into Twig:
Context::render()(and$c->view()with
a template string) now automatically injects all registered signals and actions as named
variables into every Twig render. No manual data-array passing required:- Signals are keyed by their user-supplied baseName (e.g.
$c->signal(0, 'count')→{{ count }}) - Actions are keyed by the camelCase form of their registration name
(e.g.'refresh-graphs'→{{ refreshGraphs }}) - Explicit entries in the
$dataarray still win on conflict - Result is memoized after the first call; zero overhead on SSE ticks
- A
_viadebug key is injected in dev mode listing all injected signal and action names
- Signals are keyed by their user-supplied baseName (e.g.
-
Context::getSignal(string $name): ?Signal: retrieve a registered signal by its
user-supplied name. Works for all scopes (TAB, ROUTE, SESSION, GLOBAL, custom). -
Context::getAction(string $name): ?Action: retrieve a registered action by its
user-supplied name. Useful in action bodies that need sibling actions without captured vars.
Security
- Session ownership enforced on
/_actionand/_sse: both handlers now verify that
the caller's session cookie matches the session that originally created the context.
A mismatch or absent cookie returns HTTP 403. Contexts with no session binding (e.g.
GLOBAL-scoped pages) remain openly accessible. - CSRF hardening on
/_action: three weaknesses addressed:- GET requests to
/_action/{id}now return HTTP 405 (Method Not Allowed), preventing
top-level cross-site navigation CSRF. - When no
withTrustedOrigins()allowlist is configured, the framework no longer allows
all origins by default; it falls back to a same-host check derived from theHost
header. AbsentOriginis only permitted in dev mode (curl/local tools); in production
it is denied. website/app.phpnow enableswithSecureCookie(true)andwithTrustedOrigins()in
non-dev mode whenCORS_ORIGINis set to a concrete origin.
- GET requests to
Bug Fixes
- Broadcast context count:
logBroadcastforroute:/pathscopes always logged0
contexts (hardcoded).syncContextsOnRoute()now returns the actual count of synced
contexts and it is passed to the logger. - Shutdown signal loop: workers no longer call
$server->shutdown()on SIGINT/SIGTERM,
which was sending SIGTERM back to the master, causing a cascading loop that left orphaned
processes holding the port. Workers now run cleanup callbacks andexit(0). The
master-processonStarthandler is the sole driver of$server->shutdown(). - Website examples: various fixes including chat-room typing indicator (hidden from the
typing user; stale user list on disconnect), file-upload and login template variable
alignment with signal baseNames, real IP extraction from proxy headers inSseHandler,
anddata-ignore-morphon the file-upload nav-confirm dialog.
Refactoring
- All 15 website example files updated to use auto-inject: signal and action variables
are no longer passed explicitly torender()/view()calls. Action closures now
receive theContextas a parameter and call$ctx->getSignal()instead of capturing
variables from the outer scope.
Documentation
- Twig docs: rewrote "Passing data to templates" section; updated reactive-text,
two-way binding, and action examples to use auto-injectedSignal/Actionobjects
({{ count.int }},{{ inc.url }}). - Views docs: removed manual signal/action passing from "Defining a view" and
"Partial updates" examples; simplified component view example. - Components docs: removed manual
count_id/count_val/inc_urlpassing from the
"Creating a component" example. - API docs: documented auto-inject behaviour on
view(); addedgetSignal()and
getAction()method entries; updated component example.
0.7.1 (2026-04-22)
Bug Fixes
- Signal native storage —
Signal::setValue()no longer pre-encodes arrays/objects as
JSON strings. Values are stored as their native PHP type and serialized once by the Datastar
SDK when building the SSE payload. Previously, arrays were double-encoded and arrived at the
client as a JSON string instead of an array. Signal::string()/Signal::bool()— both methods now guard against array/object
values to avoid PHP "Array to string conversion" notices.
New API
Signal::array(): array— convenience accessor that returns the signal value cast to
a PHP array, consistent with the existingint(),float(),string(), andbool()casts.
Dependencies
- Upgraded Datastar JS client to
v1.0.1(public/datastar.js). - Upgraded Datastar PHP SDK to v1 final; pinned
starfederation/datastar-phpto stable^1.0(resolved to1.0.0) in root and website lockfiles. - Removed
src/Via.phpfrom PHPStan analysis (OpenSwoole stub gap forEvent::EVENT_READ); suppressed false-positivealwaysTruewarning inSseHandler. - CI: install
website/vendorbefore running PHPStan.
v0.7.0 (2026-04-10)
New Features
-
Proactive GC timer —
Vianow runsgc_collect_cycles()on a configurable periodic timer (default 30 s) to prevent PHP's cycle collector from causing unpredictable mid-request pauses as circular references accumulate in the long-running process.Config::withGcInterval(int $ms)— set interval in milliseconds; pass0to disable and rely on PHP's automatic triggerVia::runGcCycle()— the GC tick body, public so it can be called from tests or triggered manually- Each run logs at
debuglevel:GC: N cycles freed, mem=X MB peak=Y MB Stats::getAll()now includesgc_runsandgc_cycles_freedcounters
-
Via::setInterval(callable $callback, int $ms): void— Process-wide recurring timer. Started automatically when the server starts, cleared on shutdown. No manualonStart/onShutdownwiring needed. -
Via::group(string|callable $prefixOrFn, ?callable $fn): RouteGroup— Register a group of routes with an optional URL prefix and/or shared middleware.// With prefix — routes declared with short paths, prefix prepended automatically $app->group('/admin', function (Via $app): void { $app->page('/', fn(Context $c) => ...); // → /admin $app->page('/users', fn(Context $c) => ...); // → /admin/users })->middleware(new AuthMiddleware()); // Middleware-only (no prefix) $app->group(function (Via $app): void { $app->page('/login/dashboard', fn(Context $c) => ...); $app->page('/login/profile', fn(Context $c) => ...); })->middleware(new AuthMiddleware());
-
MessageBroker — pluggable multi-node broadcasting —
broadcast()now propagates across workers/servers via a swappable broker. Ships withInMemoryBroker(default, single-node),RedisBroker, andNatsBroker.- Both brokers support auth (
$password/$authToken,#[\SensitiveParameter]), TLS ($tls,$tlsCaFile), and a custom channel/subject param to isolate traffic - Auto-reconnect with exponential backoff (1 s base, 30 s cap);
Config::onBrokerError()for observability Config::withBroker()/Config::getBroker()for configuration- TAB scope skips broker publish (no cross-node recipients possible)
- Both brokers support auth (
-
GET /_health— JSON health endpoint:{"status":"ok"|"degraded","broker":{…},"connections":{…}}. HTTP 503 when broker is in reconnect backoff. -
Scope injection protection — broker wire scopes are validated via
Scope::isValidWireScope()beforesyncLocally(); invalid scopes are logged and dropped.
v0.6.0 (2026-04-08)
New Features
-
Cookie helpers — Safe, coroutine-friendly cookie access on
Context:$c->cookie(string $name): ?string— read a request cookie (replaces$_COOKIE, which is unsafe in OpenSwoole)$c->setCookie(string $name, string $value, ...)— queue a cookie for the response; safe defaults:secure: true,httpOnly: true,sameSite: 'Lax'$c->deleteCookie(string $name, string $path = '/')— expire a cookie (setsexpires=1, empty value)- Queued cookies are flushed to the HTTP response by
RequestHandler(page load) andActionHandler(action). SSE streams seal their headers after the first write, so cookies must be set before or via an action response.
-
File upload support —
$c->file(string $name): ?arrayreturns the upload array (name,type,tmp_name,size) for multipart form submissions, ornullif missing or errored. Use Datastar'scontentType: 'form'modifier to submit a<form enctype="multipart/form-data">as a real multipart POST. -
Multipart signal parsing —
Via::parseSignals()(public static) handles three signal sources: GET?datastar=<json>, JSON body (standard actions), and$post['datastar']field (urlencoded forms). The existingreadSignals()is now a thin wrapper.via_ctxfallback extracted from$request->postfor multipart actions where Datastar sends no signals. -
Brotli compression — Native PHP brotli compression via
ext-brotli, served over HTTPS/HTTP2 from OpenSwoole directly (no proxy required). Requires eitherwithCertificate()orwithH2c().Config::withBrotli(bool $enabled, int $dynamicLevel = 4, int $staticLevel = 11)— enable brotli with configurable levels. Dynamic level (4) is used for pages and SSE streams (hot path, low CPU). Static level (11 = max ratio) is used for static assets, lazy-compressed once and cached in memory per process.Config::withCertificate(string $certFile, string $keyFile)— direct TLS termination in OpenSwoole. Enables HTTP/2 automatically.Config::withH2c(string $enabled)— h2c (cleartext HTTP/2) for proxy scenarios where Caddy/Nginx handles TLS and proxies to OpenSwoole via h2c. Satisfies the brotli HTTPS requirement without needing a cert on the PHP side.BrotliMiddleware— PSR-15 setWriter-style middleware. Attachesbrotli_writeandbrotli_finishcallables as PSR-7 request attributes before delegating. ImplementsSseAwareMiddlewareso it also runs on SSE handshake requests. Auto-registered as the outermost global middleware when brotli is enabled.- Hard error at
start()if ext-brotli is missing or HTTPS/h2c is not configured.
Improvements
- SSE: removed unnecessary 30-second keepalive comment (not needed with HTTP/2 or Caddy; was corrupting brotli streams).
- Contact Form example — Demonstrates multipart file upload, server-side per-field validation, and block re-rendering via SSE. State shared through PHP reference captures (no client-reactive signals needed).
Tests
- Unit tests now run by default —
phpunit.xmlupdated to includetests/Unit/in the default test suite.vendor/bin/pestnow runs all 236 tests (Feature + Unit). - SignalFactory test semantics corrected — Scoped signals intentionally do NOT update their value on re-registration (only the first registration uses
$initialValue). This prevents a re-render or a second joining context from overwriting live shared state with a stale initial value. Test expectations updated to match;setValue()is the documented mutation path.
v0.5.0 (2026-03-25)
New Features
- PSR-15 Middleware — Full middleware support with global and per-route registration. Global middleware runs on all page/action requests via
$app->middleware(). Per-route middleware via$app->page('/admin', ...)->middleware(new AuthMiddleware()). Implements the onion model with zero overhead when no middleware is registered. OpenSwoole requests are converted to PSR-7 at the boundary and back.SseAwareMiddlewaremarker interface for middleware that should also run on SSE handshake requestsMiddlewareDispatcher— onion-style PSR-15 pipeline executorPsrRequestFactory/PsrResponseEmitter— OpenSwoole ↔ PSR-7 adaptersRouteDefinition— fluent API for route + handler + middleware- Middleware attributes bridged to
Context::getRequestAttribute()/Context::getRequestAttributes()
- Per-session data storage —
Context::sessionData(),setSessionData(),clearSessionData()for server-side per-session state keyed on session cookie. Survives page refreshes and context destruction (unlike signals). Context::input(string $name, mixed $default)— Safe replacement for$_GET/$_POSTaccess. Checks POST first, then query string. Coroutine-safe in OpenSwoole (superglobals are not).- CSRF protection —
ActionHandlervalidates theOriginheader against a configurable allowlist (Config::withTrustedOrigins()).null= no restriction (dev),[]= block all,['https://...']= strict allowlist. - Secure session cookies —
Config::withSecureCookie(true)enables__Host-cookie prefix (enforces HTTPS,Path=/, noDomain),SameSite=Lax. - Action rate limiting —
Config::withActionRateLimit(int $max, int $window)enables per-IP sliding-window rate limiting on action endpoints (returns 429 +Retry-After). - Proxy trust —
Config::withTrustProxy(bool)gatesX-Base-Pathheader processing behind an explicit opt-in. - NATS Visualizer example — JetStream, durable consumers, KV heartbeats with OpenSwoole-native NatsClient.
- Login Flow example — Now demonstrates PSR-15
AuthMiddlewarewith a public login form and a middleware-protected dashboard route. Auth data flows via request attributes. - Middleware docs page — Full documentation covering global/per-route middleware, writing middleware, SSE-aware middleware, request attributes, and built-in security features.
Security
- Superglobal elimination — Removed all
$_GET/$_POST/$_FILES/$_SESSIONwrites fromRequestHandler. Migrated 7 example files (12 call sites) from superglobals toContext::input()/Context::sessionData(). - XSS fix —
dump()Twig function output is now HTML-escaped (htmlspecialchars). Previously markedis_safe => ['html']without escaping. /_statsendpoint — Now gated behinddevMode. Previously exposed client IPs and memory usage to unauthenticated requests.
Improvements
- Shopping Cart — Migrated cart storage to
sessionDataAPI. - Wizard example — Wizard state persists across page refreshes via
sessionData. - Updated API docs with middleware, sessionData, input(), and security Config options.
- Updated comparisons table: Auth/middleware marked as ✓ (was "coming soon").
Dependencies
- Added
psr/http-server-middleware^1.0 andnyholm/psr7^1.8 - Added
tuupola/cors-middleware^1.5 (website project)
Chore
- Added
.gitattributesto exclude dev directories from Composer distribution. - Removed legacy
examples-source/folder and stalevia:cuttemplate markers.
v0.4.3 (2026-03-23)
New Features
Context::removeScope(string $scope)— Remove a scope from a live context so it no longer receives broadcasts targeting that scope. TAB scope is protected. Backed by newVia::unregisterContextInScope().- Live Search example — Instant client-side filtering with debounced signal updates. Demonstrates TAB-scoped input signals and conditional rendering.
- Shopping Cart example — Multi-item cart with quantity controls, subtotals, and a running total. Demonstrates multiple TAB-scoped signals and computed view state.
- Theme Builder example — Live colour/font customiser with full undo/redo history. Demonstrates TAB-scoped signal stacks and action composition.
- Multi-step Wizard example — Guided form with step validation, progress indicator, and review step. Demonstrates TAB-scoped step state and conditional block rendering.
- Live Auction example — Real-time shared auction with countdown clock, anti-snipe bid extension, bid history, and sold state. Demonstrates ROUTE scope + timer broadcasting with
cacheUpdates: false. - Type Race example — Multiplayer typing race with custom per-room scope, live progress bars, WPM tracking, 3-second countdown, and "Race Again" that resets in-place and absorbs lone waiters from other rooms via live scope migration (
removeScope/addScope).
Improvements
- Chat Room messages persisted to SQLite (last 50 per room).
v0.4.2 (2026-03-19)
Bug Fixes
- SSE reconnect race — UI hangs with HTTP 400 after a few minutes — When a
client's SSE connection drops and immediately reconnects, two coroutines
briefly overlap. The old coroutine's exit path unconditionally called
scheduleContextCleanup(), firing a 5 s timer that destroyed the still-live
context. The new SSE loop then polled a closed channel indefinitely, and every
subsequent action returned 400. Fixed by tracking active SSE coroutine count
per context (Via::$activeSseCount) and only scheduling cleanup when the last
coroutine exits. A safety-valve reload is also sent if the context is found
destroyed mid-loop.
Improvements
- Debuggable TAB-scoped action IDs — TAB-scoped actions now prefix their
random hex ID with the action name when one is provided (e.g.
resize-3df6c542507ab8e1instead of3df6c542507ab8e1), making request logs
readable without affecting uniqueness or security.
v0.4.1 (2026-03-19)
Bug Fixes
-
Timer leak via
Context::interval()—interval()calledTimer::tick()directly, bypassing
ContextLifecycle. Timers were never recorded and therefore never cancelled on context cleanup.
After extended uptime, leaked timers accumulated
and eventually drove the process to 100% CPU. Fixed by delegating tolifecycle->registerTimer()
so every timer is tracked and cleared on cleanup, consistent withsetInterval(). -
Callback accumulation in
scheduleContextCleanup()— the method was called on every SSE
disconnect, including reconnections, and each call unconditionally appended a new closure to
ContextLifecycle::$cleanupCallbacks. A tab reconnecting N times accumulated N closures, growing
without bound for the context's lifetime and contributing to memory pressure and GC load under
prolonged uptime. Fixed by tracking registration state in$viaUnsetCallbackRegisteredand
skipping duplicate registrations.
v0.4.0 (2026-03-18)
Features
-
Auto-block view rendering —
view()now accepts ablock:named parameter. On SSE updates the
named Twig block is extracted and sent instead of the full page, eliminating the need to manually
thread$isUpdatethrough view callables.// Before $c->view(fn (bool $isUpdate) => $c->render('todo.html.twig', $data, $isUpdate ? 'demo' : null)); // After $c->view(fn () => $c->render('todo.html.twig', $data), block: 'demo');
The block content must have a root element with a unique
id— Datastar uses it to find the morph
target in the DOM. -
clientWritableflag for scoped signals — Scoped signals (ROUTE / SESSION / GLOBAL / custom)
are now server-authoritative by default: client-sent values are silently ignored, preventing
arbitrary clients from overwriting shared state. PassclientWritable: trueto opt a scoped signal
in to client writes:// Server-authoritative (default) — client cannot overwrite $counter = $c->signal(0, 'count', Scope::ROUTE); // Collaborative — client may push values (e.g. data-bind on a shared input) $note = $c->signal('', 'note', Scope::ROUTE, clientWritable: true);
TAB-scoped signals (the default) are always client-writable and unaffected by this change.
Bug Fixes
- Fixed examples (GameOfLife, ChatRoom, ClientMonitor) that were sending full-page HTML patches
instead of only the dynamic block. All three now useblock: 'demo'and emit partial patches.
Website
- Applied
block:to all examples with a named update block (GameOfLife, ChatRoom, ClientMonitor,
Todo, Components) - Added Signal Injection and Block Rendering test suites (17 new tests)
- Updated docs: views, FAQ, and API reference reflect
block:convention andclientWritableflag
v0.3.0 (2026-03-17)
Features
- App-level hooks —
onClientConnect()/onClientDisconnect()callbacks fire when SSE connections open or close - Per-context overrides — custom shell template, head/foot HTML per page via
ContextAPI - Static file serving — built-in for development; serve CSS/JS/images without a reverse proxy
- TUI request logger — colorful structured terminal output with method/status/timing glyphs
- Component re-rendering — components participate in broadcast sync; dirty components re-render on page-level broadcasts
- Performance — skip re-rendering clean components during page sync, reducing unnecessary SSE patches
Bug Fixes
- fix:
Coroutine::sleep()TypeError on OpenSwoole — useusleep()withSWOOLE_HOOK_ALL - fix: broken signal approach in live-poll replaced with
patchElements - fix: component re-rendering wired into broadcast sync correctly
- fix: shell template path co-located with
HtmlBuilder(no more../../templates/relative path)
Website
- Consolidated examples — 11 standalone example apps merged into the website's single Via server under
/examples/{name} - Tabbed source panel — each example shows PHP handler and Twig template in switchable tabs (CSS-only, no JS)
- Example summaries — 3–6 paragraph descriptions per example explaining the concepts demonstrated
- Examples-source accuracy — all source display files updated to match actual handler logic (board model, scope prefixes, signal names, template variables)
- Client Monitor revamp — replaced timer-driven polling with
onClientConnect/onClientDisconnecthooks - Removed Global Notifications example (concepts merged into All Scopes)
- All Scopes redesign — CSS class-based cards replacing inline styles, reduced emoji usage
- Docs section — FAQ entries for
PatchElementsNoTargetsFoundand duplicate ID collision pitfalls; design philosophy page; comparison table with Phoenix LiveView column - Home page overhaul — tabbed code demos, glass UI, scope badges, animations
- Twig
{% code %}tag — syntax highlighting viambolli/tempest-highlight-datastarpackage