Velocity engines — Qbix, FrankenPHP, PHP's built-in server
Read this page if you run Exponential under Velocity (exp:velocity, vc), choose between its engines, or deploy
PHP changes to a site that Velocity serves. Velocity drives one of three web servers with the same verbs (start,
stop, graceful, restart, kill, status, deploy, command, config, install) and the same
velocity.ini.
Warning
.htaccess slows down every request. When .htaccess files are enabled (AllowOverride anything but None), let alone
used, Apache looks for them and reads and evaluates them on every request, in every directory of the path, and
page loading times go up drastically (measured). The only real fix is to switch the feature off for
the virtual host: AllowOverride None in the HTTPS virtual host, and the rules in the virtual host's own
configuration as mod_rewrite rules. That is far faster in production speed tests and well worth the time it takes
to optimize a production configuration. Switch over right away: copy the rules of the
example HTTPS virtual host into your virtual host (with the static cache, also
staticcache_offload.conf), set AllowOverride None, run apachectl configtest, reload Apache and
test the site. The same rules for other servers: nginx, LiteSpeed,
Caddy, IIS, Velocity (built in); where virtual host rules go in a control
panel: Plesk, cPanel, DirectAdmin, ISPConfig.
On LiteSpeed or OpenLiteSpeed, the server's own page cache can also answer anonymous visitors and crawlers without PHP: Page caching with LiteSpeed and OpenLiteSpeed.
Velocity is part of Exponential as of 6.0.15
As of 6.0.15 the root composer.json requires se7enxweb/exponential-velocity ~0.0.4.57 (through se7enxweb/exponential-php-floor, on PHP 8.0 and later); before, it only
suggested it. What that changes for an existing installation:
| What changed | composer install installs the Velocity package with the rest of Exponential, so the qbix engine is always available; exp:velocity start --engine=qbix no longer stops with velocity: no server script at ... on a fresh install. |
| PHP | Velocity 0.0.4.54 runs on PHP 8.0 to 8.5, the same range as the kernel; it needs ext-sockets (pcntl and openssl recommended). |
| What does not change | The shipped default engine stays php; a site served by Apache, nginx or FrankenPHP keeps being served that way. Nothing starts a server on its own. |
| How to check | php bin/php/console exp:velocity status reports the engine package and its version. |
| What to do | Nothing beyond composer install. To use the server, set Engine=qbix and deploy with exp:velocity deploy. |
This is the turning point of the 6.0 line: the Internet / Web Application Server is a cornerstone of the core product, and the whole path from a request to a rendered page belongs to one product. Velocity's own server is the recommended engine for every stage; FrankenPHP is the production-ready alternative. The story for every reader: Velocity is part of Exponential 6.0.15.
In short
| What changed | velocity.ini [ServerSettings] Engine chooses the default engine: php (shipped), qbix (recommended) or frankenphp. exp:velocity deploy runs every step a PHP change needs, in order, with PASS, FAIL or SKIP. Several Qbix servers can share one port (Instances). |
| Who is affected | Every Velocity user. A changed PHP class reaches Velocity's workers only after a restart; deploy does it in the right order. The PHP-FPM that serves the site must be named in [DeploySettings] PhpFpmService when auto does not find it. |
| How to check | php bin/php/console exp:velocity status and php bin/php/console exp:velocity deploy --dry-run --allow-root-user |
| How to fix | Set Engine=qbix for any real site; deploy PHP changes with exp:velocity deploy (add --kernel when a kernel class was added or renamed). |
[ServerSettings]
Engine=php # shipped default: development, always works
Engine=qbix # recommended: every stage, development to production
Engine=frankenphp # production-ready alternativeEngine is the default engine — the one start, stop and status use
without --engine. It ships as php, because PHP's built-in server needs
nothing an installation might lack; any site, from development to production,
sets qbix (Velocity's own server), the recommended and fastest engine. What
an engine is for (its role) does not change with that.
qbix | frankenphp | php | |
|---|---|---|---|
| Role | recommended: every stage, development to production | production-ready alternative | development (shipped default) |
| Port (shipped) | 8088 ([ServerSettings]) | 8089, HTTPS 8444 | 8087 |
| What | the bundled Qbix server, a PHP process | FrankenPHP: Caddy with PHP built in, one binary | php -S, PHP's own server |
| Where it comes from | Composer (se7enxweb/exponential-velocity, formerly se7enxweb/qbix-webserver) | downloaded by exp:velocity install, SHA-256 checked | the PHP that runs exp:velocity |
| PHP | the machine's | the binary's own (official 1.12.x: PHP 8.5) | the machine's |
| Requests | persistent or per-request workers | classic mode: a thread pool, clean state per request | Workers processes, clean state per request |
| Response cache | yes ([CacheSettings]) | not yet | no |
| TLS / HTTP/2 | yes | yes | no |
| graceful | re-exec, socket kept | configuration reload, no dropped connection | restart |
Config tree /etc/vc | yes | no — a generated Caddyfile | no — a router script |
Switch one command to another engine with --engine, several with
--engine=php,qbix, all with --all.
Deploying a PHP change (exp:velocity deploy)
One command does everything a changed PHP class, operator or INI file needs before it is live, in the one order that works, and says PASS, FAIL or SKIP for each step with its time:
php bin/php/console exp:velocity deploy --allow-root-user # the usual case
php bin/php/console exp:velocity deploy --kernel --allow-root-user # a kernel class was added or renamed
php bin/php/console exp:velocity deploy --dry-run --allow-root-user # what it would do, and nothing else| # | Step | By hand |
|---|---|---|
| 1 | extension autoloads | php bin/php/ezpgenerateautoloads.php -e |
| 2 | kernel autoloads, only with --kernel | php bin/php/ezpgenerateautoloads.php -k, restricted to kernel/ and lib/ |
| 3 | INI caches, and the compiled kernel service container (cache item kernelservices, kernel services), when settings changed or with --kernel | php bin/php/ezcache.php --clear-tag=ini (./console exp:services:compile for the container alone) |
| 4 | template, template-override, translation and design_base caches, each when what it holds can have changed (below) | --clear-id=template, --clear-id=template-override, --clear-id=translation, --clear-id=design_base, one id per call |
| 5 | engine archive, when Velocity runs from one | php bin/php/phar.php build: rebuilt only when a file in kernel/, lib/ or autoload/ changed, every file parsed first |
| 6 | PHP-FPM: wait until its OPcache has revalidated the changed files (no reload), or reload it when that cannot be relied on | sleep <revalidate_freq + 1>, or systemctl reload <[DeploySettings] PhpFpmService> |
| 7 | restart Velocity, when it is running | exp:velocity restart |
| 8 | content, exphttpcache, ezjscore-packer (only with --packer) and template-block caches, when templates, design files, settings or translations changed | --clear-id=content, --clear-id=exphttpcache, --clear-id=ezjscore-packer, --clear-id=template-block |
| 9 | Velocity's response cache, with step 8 | exp:velocity cache clear |
| 10 | warm-up: [DeploySettings] WarmUpURL[] requested once each by a background process | curl URL... |
config.php counts as a PHP change. The qbix engine's persistent workers
read it once, when they start. After ./console exp:config:set <NAME> <value> --apply (or exp:setup:config --force --apply), run exp:velocity restart,
or deploy, which restarts in step 7. FrankenPHP in classic mode and PHP-FPM
read it on every request once OPcache sees the new file; with
opcache.validate_timestamps=0, reload PHP-FPM
(Setting up .htaccess and config.php from the console).
The order is the point. The caches that hold rendered output (8 and 9) are cleared only after PHP-FPM and Velocity run the new code. Cleared before, every page requested in between is rendered by the old code and cached again, and the change looks as if it had not worked.
A cache is cleared only when what it holds can have changed. A deploy that
passes records when it started (var/cache/velocity-deploy/last.json); the next
one looks for files modified since then below design/, settings/,
share/translations/, the same directories of every extension, and the code of
the template engines and operator registrations (lib/eztemplate, lib/smarty,
lib/twig, lib/templatebridge, kernel/common, extension/*/autoloads):
| Changed since the last deploy | Cleared |
|---|---|
| settings | INI caches, template-override, design_base, the caches of rendered output (steps 8 and 9) |
| templates or other design files | template, template-override, design_base, the caches of rendered output |
| translations | translation, the caches of rendered output |
| template engine or operator registration code | template (compiled templates) |
| only PHP classes | nothing of steps 3, 4, 8 and 9 |
So a deploy of PHP classes keeps the pages already rendered, and the first
visitors after it do not each wait for a page to be rendered again. --dry-run
says for each cache why it would be cleared or kept, naming the changed files.
--clear-content clears the caches of rendered output anyway (after a change of
PHP code that changes what a page shows), --all-caches clears every cache. With
--kernel the INI caches are cleared too (the kernel service container is
compiled again). Without a record of an earlier deploy (the first one, or the
file removed) every cache is cleared, as before; an upgrade
(exp:upgrade) deploys with --all-caches. A file is "changed" by its
modification time: a copy that keeps the old time (cp -p, rsync -t) is not
seen, so deploy such a copy with --all-caches. --caches=<list> clears the caches it names (ids or
tags of ezcache, tag:<name> for a tag) whatever changed: a migration run reports the caches its steps touched
with exactly that deploy (./console exp:velocity deploy --caches=content,template-block --apply), since a run that
changes only the database changes no file the deploy would see. --migrate runs the pending migrations
(exp:migration:migrate --apply) after the caches of settings and templates and before the engine archive, PHP-FPM
and Velocity; [DeploySettings] Migrate=enabled makes every deploy do it, --no-migrate skips it
(Migrations: packages, deploys and long runs).
The first step that fails stops the run. A kernel file that does not parse
fails step 5, before PHP-FPM or Velocity has been touched; a restart that does
not come up leaves every cache as it was. The exit status is 1, and the steps
not run are listed as NOT RUN.
PHP-FPM is reloaded only when it has to be ([DeploySettings] PhpFpmReload=auto). A reload is not confined to this site: the master asks
every child of every pool to finish its request and starts no new child until
the last one has, for up to process_control_timeout (300 s on Plesk). Every
request that arrives meanwhile, on any site of that PHP version, waits for the
longest request still running. Measured on one host: an open 25 s event
stream made the next page wait 22.4 s after a reload, and one with no stream
of its own still waited 3.9 s for other sites' requests; a 135 s request
elsewhere turned the waiting ones into 504s. When the pool's OPcache
revalidates files (opcache.validate_timestamps on, revalidate_freq at
most 60 s, read from php-fpm -i and the pool file), each changed PHP file
is recompiled within revalidate_freq seconds anyway, so step 6 waits that
long plus one second instead, and the rendered-output caches are still
cleared only after it. --fpm-reload (or PhpFpmReload=always) reloads
anyway: after a php.ini or pool change, a new PHP extension, or PHP files
removed or renamed in place.
The first visitor after a deploy does not rebuild the caches. Step 10
requests each [DeploySettings] WarmUpURL[] once (one after the other, by a
detached curl, without waiting for it): a front page and an admin login page
per engine compile the page layouts and rebuild the INI, override,
translation, design and packed-file caches before anyone asks, and several
first visitors no longer all rebuild them at once.
design_base is always cleared. It holds the list of design directories as it was when the extensions were last read, so after an extension is activated its templates are not found until it is cleared: the page answers 200 with an empty content area.
The packed scripts and styles go with the template blocks. The admin's page
head is kept in a template block, and names the packed files. Clear
ezjscore-packer on its own and those heads point to files that no longer
exist: the admin loses its scripts and styles until template-block is cleared
as well. --packer therefore clears it in step 8, immediately before
template-block, and never anywhere else. It is needed only when what a packer
server function returns has changed (an ezjscServer_* class, or the settings
it reads); an edited .js or .css file gets a new packed file by itself,
since the packed file's name carries the source files' time.
Options: --kernel, --no-autoload, --no-fpm, --fpm-reload (reload PHP-FPM even when its OPcache revalidates), --no-velocity,
--rebuild-phar (rebuild the engine archive even when it is current),
--packer (clear the packed scripts and styles, together with the template
blocks), --engine=<name> (the engine to restart), --dry-run, --json.
The archive build checks files 200 to a php -n -l call instead of one process per file. With 1,049 files that took nine minutes per build before; it now takes about two seconds for the checks and about 26 for the whole build. A build also writes dist/engine.phar.index.json, so exp:phar build and every start, restart and graceful rebuild only when something in the archive changed.
velocity deploy: /var/www/vhosts/example.com/doc/example.com
[ 1/14] PASS 1.4s extension autoloads: var/autoload/ezp_extension.php written
[ 2/14] PASS 0.3s INI caches: Clearing ini: Query cache (SQL results), Global INI cache, INI cache, ...
[ 3/14] PASS 0.4s template cache: Clearing template: Template cache
[ 4/14] PASS 0.3s template-override cache: Clearing template-override: Template override cache
[ 5/14] PASS 0.6s translation cache: Clearing translation: TS Translation cache
[ 6/14] PASS 1.3s design_base cache: Clearing design_base: Design base cache
[ 7/14] PASS 31.7s engine archive: engine.phar rebuilt: 1 changed (kernel/classes/expvelocitydeploy.php)
[ 8/14] PASS 0.2s reload PHP-FPM: reloaded plesk-php85-fpm (auto: pool /opt/plesk/php/8.5/etc/php-fpm.d/example.com.conf)
[ 9/14] PASS 7.5s restart Velocity: 4 instances started (engine.phar is current, not rebuilt)
[10/14] PASS 0.3s content cache: Clearing content: Content view cache
[11/14] PASS 0.4s exphttpcache cache: Clearing exphttpcache: HTTP cache (role-aware pages)
[12/14] PASS 0.4s ezjscore-packer cache: Clearing ezjscore-packer: eZJSCore Public Packer cache
[13/14] PASS 0.4s template-block cache: Clearing template-block: Template block cache
[14/14] PASS 0.0s Velocity response cache: response cache cleared (...)
velocity deploy: PASS, 14 steps in 45.2sThis run had --packer (step 12); without it there are 13 steps.
With no file in the engine archive changed, step 6 says engine.phar is current, not rebuilt and the whole run takes about 7 seconds.
Which PHP-FPM
[DeploySettings]
PhpFpmService=auto # the default
PhpFpmService=disabled # served by Velocity alone
PhpFpmService=plesk-php85-fpmauto finds the pool configuration named after the installation's hosting
domain (/var/www/vhosts/<domain>/..., as Plesk names pools) or one that names
the installation's directory, in /opt/plesk/php/*/etc/php-fpm.d (service
plesk-php<XY>-fpm), /etc/php-fpm.d (php-fpm), /etc/php/*/fpm/pool.d
(php<X.Y>-fpm) and /etc/opt/remi/php*/php-fpm.d (php<XY>-php-fpm). The
service is the pool's master, not the machine's php-fpm if that is another
one: on a Plesk server systemctl restart php-fpm restarts a PHP no site of
that kind runs on. --dry-run shows what was found and why.
The step is a reload, so no request is dropped. It needs root; run as anyone else, or with no pool found, it is skipped with a note naming what to do.
Kernel autoloads
ezpgenerateautoloads.php -k walks the whole installation. If you keep working copies of the installation inside it
(for example git worktrees in a hidden directory), every kernel class is found twice and the array can end up
pointing into the copy: a plain -k run on one installation wrote 1029 entries, every one of them inside such a copy,
and took 17.6 seconds. deploy --kernel excludes every top-level directory but kernel/ and lib/, which is where
the kernel's classes are, and takes under a second for the same array. For a run by hand, list such directories in
.autoloadignore (one pattern per line, anchored at the installation root) or pass --exclude=<dir>.
A restart or deploy that is cut off
exp:velocity restart, and the restart step of deploy, run the stop and the start as one detached unit. The command
re-launches itself in its own session (setsid -f) and, as root under systemd, in a transient scope named
vc-restart-<date>-<token> (systemctl list-units 'vc-restart-*'), then waits for it and prints what it printed, with
its exit code. A caller that is killed between the stop and the start (a timeout, a spend limit, a closed terminal,
the end of a login session) no longer leaves the server down: the restart finishes on its own. The server it starts
lives in that scope, not in the caller's session.
While it runs, restart-in-progress.json lies next to the pid file (var/vc/qbix/run/), with the detached
process's pid, its scope and when it began; it goes when the start has finished. Its output is
restart-<token>.log in the same directory until the caller has read it.
An explicit exp:velocity stop (or kill) writes stopped.json there; start, restart and graceful remove it.
The server is down on purpose only while that file is there. deploy therefore starts a server that is down without
it (a restart cut off before this change, or a crash) and leaves one that was stopped on purpose alone.
The server's priority is [ServerSettings] Nice (default 5), whatever the caller runs at: a deploy run under
nice -n 19 used to leave every worker at 19. exp:velocity status names a parent that runs at another value.
Nice=inherit keeps the caller's.
[ServerSettings]
Nice=5 # inherit: the caller's priority, as before
[ControlSettings]
DetachedRestart=enabled # disabled: restart in the calling process, as beforeEXP_VELOCITY_DETACHED_RESTART=0 in the environment turns the detaching off for one call.
The watchdog (exp:velocity watchdog)
A restart is a stop followed by a start. If whatever runs it is killed in between, the server stays down until somebody starts it. The restart now runs as one detached unit, so that should not happen again. The watchdog is a second safety net for what still can: a crash, an out-of-memory kill, or a stop by some other tool.
It is off until you install it (root, systemd):
./console exp:velocity watchdog install --apply --allow-root-user # vc-watchdog.service + .timer, conf-available/watchdog.conf, enabled
./console exp:velocity watchdog status --allow-root-user # timer, markers, last check and last start
./console exp:velocity watchdog run --allow-root-user # one check now (--json for a script)
./console exp:velocity conf disable --apply watchdog --allow-root-user # pause it (conf enable: resume)
./console exp:velocity watchdog remove --apply --allow-root-user # timer off, units and configuration file removedEvery Interval seconds (45 by default) the timer runs one check of the default engine (or the engine named with
--engine). It starts the engine only when all of these are true:
- no process of the engine runs. A server that runs but does not answer is left alone: the watchdog never stops, kills or restarts a running server;
- nobody stopped it on purpose.
exp:velocity stopandkillwritestopped.jsonbeside the pid file (var/vc/qbix/run/), andstart,restartandgracefulremove it; - no restart is in progress. The detached restart writes
restart-in-progress.jsonand removes it when its start has finished. A marker whose process is gone, or that is older thanRestartTimeout, no longer counts; - the optional
LockFileis not held (a deploy queue). If the lock is held for longer thanRestartTimeoutwhile the server is down, it no longer counts either; - it is not backing off. After three failed starts in a row, it waits
Backoffseconds before trying again.
The server it starts goes into a systemd scope of its own (vc-<engine>-watchdog-start-<time>). Without that,
systemd would end the server together with the watchdog's oneshot service, and the service also has
KillMode=process. Every check that does more than find the server running writes one line to
var/log/velocity-watchdog.log (in the log viewer as "Velocity watchdog"):
[2026-10-17T17:02:15+00:00] qbix action=start reason=down detail="not running, down for 0 s; start: ..." pid=12345
[2026-10-17T17:03:00+00:00] qbix action=skip reason=intentionally-stopped detail="stopped on purpose 40 s ago; ..." pid=12360The reasons are down, restart-stale, intentionally-stopped, restart-in-progress, lock-held, backoff,
paused, overlap (the previous check is still busy) and error.
The audit (doc/eng-us/bc/6.0/audit.md) gets one event only from a check that acts or decides something notable, never from
the routine "running, nothing to do" check. A watchdog run does not record the generic system.command.run that
every other command records (it calls eZScript::setAuditCommandRun( false ) for its own run). Instead it records:
| Event | When | Reasons |
|---|---|---|
system.velocity.watchdog.start | it started the engine (success), or the start failed (failed, warning) | down, restart-stale |
system.velocity.watchdog.backoff | it did not try, three starts in a row having failed | backoff |
system.velocity.watchdog.skip | it left a down engine alone | intentionally-stopped, restart-in-progress, lock-held, overlap, error |
The reason and the detail are in the event's after. A check that finds the engine running (none), and a paused
watchdog, record nothing.
A skip is recorded only when its reason changes: the first skip of a period (a server stopped on purpose, a restart
in progress, the lock held, an overlap, an error), or a skip with a different reason than the last one recorded. The
watchdog keeps that last reason in watchdog-last.json beside the markers (var/vc/<engine>/run/) and removes the
file as soon as a check finds the engine running or starts it, so the next stop gives a skip event again. A server
stopped on purpose for a day gives one event, not some 1,900. start and backoff events are recorded as before.
velocity-watchdog.log is not deduplicated: it keeps one line per check that does more than find the server running,
so the log still shows every check of a stopped period.
[WatchdogSettings]
Interval=45 # seconds between checks (15-3600)
RestartTimeout=300 # how long a restart marker or a held lock may hold it back
Backoff=600 # the wait after three failed starts in a row
LockFile= # a lock (flock) that means "deploying, wait"; empty: none
UnitName=vc-watchdog # give each installation on a host its own
UnitDir=/etc/systemd/systeminstall writes these values to conf-available/watchdog.conf in the configuration tree (/etc/vc), as JSON under
watchdog, and enables it in conf-enabled. Values in the enabled file win over velocity.ini, so a host can
change them without an INI override. An existing file is kept when you install again.
Running them side by side
For tests the three can run at the same time: each has its own port, pid file, logs and configuration, so nothing collides.
php bin/php/console exp:velocity start --all --allow-root-user # php :8087, frankenphp :8089, qbix :8088
php bin/php/console exp:velocity status --all --allow-root-user # role, default, running, port for each
php bin/php/console exp:velocity stop --engine=qbix --allow-root-user
php bin/php/console exp:velocity stop --all --allow-root-user--all and engine lists work with start, stop, restart, graceful, kill and
status. Each engine is reported on its own line; one that fails does not stop
the others, and the exit code is non-zero if any did.
status shows the addresses to open as full URLs, which a terminal makes
clickable, for each running engine: every site reached by a path (the default
siteaccess at /, then the others of AvailableSiteAccessList; left out when
siteaccesses are matched by host only), the admin login, Setup > System
information and Setup > Caches, the HTTPS address beside each when the engine
serves TLS, and the Qbix server's dashboard. A stopped engine shows where it
will answer and the command that starts it. Below the addresses the process,
version, configuration, HTTPS (the port, and when it is off what switches it
on) and the logs -- paths relative to the installation -- and the settings the
engine does not act on, one per line. status without --engine begins
with one line per engine -- role, state, URL -- and the command that stops
each one running, then shows the details of those running (the default's when
none is), so an engine left running from a test is seen. status --all shows
the same overview and then the complete status of every engine, stopped ones
included with every address they will answer at: everything, and every URL,
at a glance.
status --engine=<name> shows that one only. start --all (and
restart/graceful --all) ends with what status --all shows; start of one
engine with the overview and that engine's complete status. The exit code and --json still describe the default engine.
[FrankenPHPSettings] and [PHPServerSettings] may set Port, HTTPSPort,
Host, Workers, SpareWorkers and DocumentRoot for their engine. An empty
value falls back to [ServerSettings], which is also the Qbix engine's own.
A site runs one engine. Setup > System information therefore describes, in detail, only the engine serving the page, and names any other engine of the installation that is running in one line, as a test setup.
Upgrading: until this change the default was qbix. An installation whose
deploy scripts run exp:velocity restart to mean the Qbix server sets
Engine=qbix in settings/override/velocity.ini.append.php.
The Qbix engine's programs: sbin/ and bin/
From Exponential Velocity 0.0.4.41 the engine package keeps its programs the way
UNIX systems do: the server and its administration commands in sbin/, the
shell in bin/ (the engine's docs/layout.md, "Programs"). Every former path
still runs the same program in the same process, so nothing in an installation
has to change, and exp:velocity works with the engine before and after that
release, deciding by which files are there:
| Engine 0.0.4.40 and earlier | Engine 0.0.4.41 and later | |
|---|---|---|
| the server | qbixserver.php | sbin/qbixserver.php; qbixserver.php forwards to it |
| control | qbixctl.php | sbin/qbixctl.php; qbixctl.php forwards to it |
| console | qbixconsole.php | sbin/qbixconsole.php; qbixconsole.php forwards to it |
| shell | qshell.php | bin/qshell.php; qshell.php forwards to it |
| archive | bin/qbixserver.phar | sbin/qbixserver.phar; bin/qbixserver.phar is the same file |
[ServerSettings] ScriptPathkeeps its shipped value,vendor/se7enxweb/exponential-velocity/qbixserver.php: with the new engine it is the forwarder, and a server started by it shows the same command line as before, sops,pkill -fpatterns and monitoring that look for it keep matching. Setting it to.../sbin/qbixserver.phpworks too; if the file it names is not there (the vendor copy is still the older engine), the other path is used.start,stop,graceful,restartandstatuscount a server of this installation whichever of the two paths it runs from: one started byexp:velocity startrunsScriptPath, one started by the engine's ownqbixctl startrunssbin/qbixserver.php(expVelocity::scriptPaths()).exp:velocity ctl,sslandextrun the engine'ssbin/qbixctl.php, orqbixctl.phpwith an older engine (expVelocity::engineFile()), andcache clearloads the engine's control class from itssrc/, which did not move (expVelocity::engineDir()).
Several Qbix servers on one port (Instances)
A Qbix server answers cached pages in its own process, before any worker is
involved: about 0.3 ms of CPU a page, so one server is bounded by one core.
[ServerSettings] Instances=N runs N servers on the same HTTP and HTTPS ports
instead. The generated configuration sets the engine's Q.webserver.reusePort,
so every instance opens its listeners with SO_REUSEPORT and the kernel spreads
new connections across them.
# settings/override/velocity.ini.append.php
[ServerSettings]
Instances=4
Workers=148 # per instance: the old 590 divided by four
SpareWorkers=12 # per instance: the old 48 divided by four- Each instance has its own worker pool, pid file and log, beside the first
one's:
server.pid,server.1.pid, ... andconsole.log,console.1.log, .... Instance 0 keeps the configured names, soInstances=1(the default) is exactly the single server as before. start,stop,killandstatusreach every instance;statuslists the parents.gracefulreloads them one at a time, so the port keeps answering.- The response cache's disk tier is shared (the same cache directory); each instance's memory and APCu tiers are its own and warm up separately.
- Only the Qbix engine: FrankenPHP and PHP's server run one process tree each.
- Linux and the BSDs (
SO_REUSEPORT).
Measured on a test installation (12 cores, 2026-09-27), cached front page over TLS with gzip:
| one instance | four instances | FrankenPHP | |
|---|---|---|---|
| anonymous, 64 concurrent | ~2,950/s | 11,985/s, p95 10 ms | 4,153/s |
| signed in (the HTTP cache), 8 concurrent | 1,435/s | 3,991/s | 1,937-3,413/s |
Full renders are not faster with more instances; they are bounded by the workers and by Exponential itself.
Stop, restart and status of the Qbix engine
What counts as this installation's server is every process of it, not only the ones the pid files name:
- a PHP process running the engine's server script (as a real path, so either
path to the same tree counts) whose
--pidis one of this installation's pid files, or, without one, whose--rootis its document root -- never a shell,timeoutor another wrapper that only mentions the script, never the engine's own--stopand--reloadcommands, neverexp:velocityitself or anything above it, and never a server of another installation or of an engine checkout's tests using the same script; - everything those started (the zygote is titled
qbixserver: zygoteand names no script); - any PHP process whose output still goes to the server log directory (the
directory of
LogFile): what a server left behind when it ended.
stop asks each instance with a pid file through the server's own --stop,
sends SIGTERM to every top-level process (the parents and anything whose parent
has gone), then to every process if the servers have not taken their pools down
within three seconds. Whatever is still there after [ControlSettings] StopTimeout (15 s) is killed with SIGKILL, and the result says so. It succeeds
only when no process of the installation is left and its ports are free; a port
still held by another program is named with its process.
restart rebuilds the engine archive first (the running server is left alone if
that fails), stops as above, starts, and reports success only when every
instance has a new parent and the HTTP port answers a request
(GET /Q/health, any status) within 30 seconds. Otherwise it fails with a
message and a non-zero exit. Because the stop finds processes without the pid
files, a restart interrupted half-way -- a caller's timeout between "stopped" and
"started" -- is finished by the next restart, and a start that finds an
instance without its parent ends the leftovers first. A start never ends a
server whose instances all have their parents, answering or not; it says
already running, but it does not answer and leaves that to restart.
status says running only for a server that answers an HTTP request (asked
twice, five seconds each); processes that do not answer are not answering, and
the command exits non-zero. Under Problem it names an instance without its
parent, processes of the installation without a running server (and the ports
they hold), connections queued on a port and not accepted, and a parent blocked
in the kernel (futex_do_wait), which acts on no signal but SIGKILL.
The server bounds its own stop too: [ControlSettings] ShutdownTimeout (12 s,
below StopTimeout) becomes the engine's Q.webserver.shutdownTimeout, after
which the server and every process it started are killed. Its workers stop at a
point where they hold no APCu lock, so a request timeout no longer leaves the
lock held and the server blocked on it (the cause of the 2026-10-07 hang).
From engine 0.0.4.50 a stop also drains gracefully: each HTTP/2 connection is sent GOAWAY with its last stream,
the streams up to it are answered in full and later ones are refused with REFUSED_STREAM so the client retries
them; an idle HTTP/1.1 keep-alive connection is closed at once. A HEAD over HTTP/2 is answered with the GET's headers
only, also from the response cache.
Streaming responses
From engine 0.0.4.49 a persistent pool worker streams a response instead of collecting it whole: an answer with
X-Accel-Buffering: no (the streamed audit export, for example) and Server-Sent Events (Accept: text/event-stream,
used by Exponential Intelligence). Four settings in settings/velocity.ini [ServerSettings] are passed to the
engine; all are empty by default, so the engine's own defaults apply:
| Setting | Engine default | Meaning |
|---|---|---|
StreamTimeout | 300 | Seconds a stream (or a request with Accept: text/event-stream before its first byte) may run instead of the 30 s request timeout; then it ends cleanly and an EventSource reconnects. 0: no limit. |
StreamShare | 0.25 | The share of Workers that open streams may hold at once (at least one). |
MaxStreams | (empty) | The same as a number of workers, used instead of StreamShare when above 0. Past the cap an event stream is answered 503 with Retry-After, so pages always keep the rest of the pool. |
RetryWithin | 10 | Seconds after a request reached its worker within which a GET whose worker died without answering is run again, once (never an event stream, never a response that had started). |
# settings/override/velocity.ini.append.php
[ServerSettings]
StreamTimeout=120
MaxStreams=8Then ./console exp:velocity restart. Each open stream holds one worker for its whole life, so keep the cap well
below Workers.
The warm-up clears the response it rendered (its headers and status) before the workers are forked, so the first request of each worker starts as empty as every later one (before 2026-10-11 it could carry the front page's headers).
FrankenPHP
php bin/php/console exp:velocity config set --apply ServerSettings Engine frankenphp --allow-root-user
php bin/php/console exp:velocity install --allow-root-user # optional: start installs it
php bin/php/console exp:velocity start --allow-root-userThe binary
install fetches the release asset for this machine from GitHub, for the
version pinned in [FrankenPHPSettings] Version, and refuses it unless its
SHA-256 matches the value pinned beside it (Sha256[<asset>]). It lands in
var/vc/frankenphp/bin/frankenphp-<version>-<asset> (git-ignored), next to a
.sha256 file. start installs it on its own when it is missing
(AutoInstall=enabled).
| Option | Effect |
|---|---|
install --check | re-hash the installed binary |
install --force | download again |
install --from=<file> | install a file copied here by hand — for a machine without access to GitHub; checked the same way |
install --trust-github-digest | for a Version with no Sha256[] lines: accept the digest GitHub publishes for the release (weaker: the same source as the file) |
Variant picks the build on Linux:
| Variant | Asset | When |
|---|---|---|
gnu (default) | frankenphp-linux-<arch>-gnu | any distribution with glibc 2.17+; faster allocator than musl, loads .so extensions (oci8 …) |
musl | frankenphp-linux-<arch> | Alpine, or where nothing may be taken from the system |
mimalloc | frankenphp-linux-x86_64-mimalloc | musl with the mimalloc allocator, x86_64 only |
macOS gets frankenphp-mac-arm64 or -x86_64; Windows is not downloaded.
A new release means changing Version and every Sha256[] line together
(the digests are on the release's GitHub API page, ApiUrl). The old binary
stays in var/vc/frankenphp/bin, so going back is only the setting.
An own build — another PHP version, extra Caddy modules (the response cache
cache-handler, for instance) — is named by BinaryPath. Nothing is downloaded
then; BinarySha256, when set, is checked by install. Such builds are made
with FrankenPHP's static-builder-gnu.Dockerfile / static-builder-musl.Dockerfile.
PHP settings
The binary reads no php.ini of the machine. Without settings PHP would run
on its compiled-in defaults — display_errors on, a 128 MB memory limit. So
[FrankenPHPSettings] IniOptions[] ships display_errors=Off, log_errors=On,
memory_limit=512M, opcache.enable=1 and the upload limits; [PHPSettings] IniOptions[] is applied first, a later key wins. PHPIniFile= points PHP at a
php.ini of your own instead (PHPRC).
OPcache needs no opcache.revalidate_freq=0 here: every request has its own
request time, so edited and regenerated PHP files are picked up.
Each value reaches PHP as ini text, where a ; starts a comment, so a value
holding one (session.save_path=0;0660;<dir>) is written quoted into the
Caddyfile. Before that was done, the session path arrived as 0, no session
was ever found and nobody could stay signed in on this engine.
[PHPSettings] IniOptions[] also sets apc.shm_size=256M for both engines:
the command-line default is 32 MB, in which the response cache and the SQL
query cache evicted each other (a Velocity render cost 727 ms of CPU, 303 ms
with 256 MB).
The generated Caddyfile
var/vc/frankenphp/run/Caddyfile is written from velocity.ini on every
start, graceful and restart, and checked with frankenphp validate before
it replaces the previous one — a broken configuration never reaches the running
server. exp:velocity ctl caddyfile prints it; ctl validate, ctl adapt,
ctl version, ctl list-modules, ctl build-info run the binary's own
commands with it.
The routing is .htaccess_root's: the listed design, extension, image and
cache paths are files, api/ goes to index_rest.php, the tree menu to
index_treemenu.php, everything else to index.php. It deliberately does
not use php_server, whose default is to serve every file that exists —
settings/*.ini, the SQLite database, the kernel sources.
Directives of your own (headers, redirects, extra routes) go in a file named by
[FrankenPHPSettings] SiteInclude, imported into every site block. Do not edit
the generated file.
HTTPS
FrankenPHP serves HTTPS on HTTPSPort (8444) beside plain HTTP on Port,
by default ([FrankenPHPSettings] HTTPS=enabled), so a development machine
has https:// from the first start:
php bin/php/console exp:velocity start --engine=frankenphp # http :8089 + https :8444
php bin/php/console exp:velocity start --engine=frankenphp --no-https # plain HTTP, this start only
php bin/php/console exp:velocity start --engine=frankenphp --https # insist on HTTPS, this start onlyHTTPS=disabled switches it off for good. It uses
[HTTPSSettings] Certificate and Key when both are set (both must exist).
With both empty it makes a self-signed certificate for this machine --
localhost, 127.0.0.1, ::1 and the host name, SHA-256, valid a year --
in var/vc/frankenphp/tls/ (key 0600) and renews it a month before it runs out.
A browser warns about it once; it is meant for development and for a reverse
proxy in front, not for visitors -- a public site names its certificate or
terminates TLS in front. When the self-signed certificate cannot be made (no
openssl extension, an unwritable directory) the server starts with plain HTTP
and the start message says why; with --https, or a named certificate that
does not exist, the start is refused instead. [HTTPSSettings] Enabled=true with a
certificate switches it on as before. status says which certificate is in
use and lists the HTTPS address beside the HTTP one, also for a server started
with --https. --https is the frankenphp engine's: the built-in server has
no TLS, and the Qbix server takes [HTTPSSettings].
Control
stop and graceful use Caddy's admin API, by default on a unix socket at
var/vc/frankenphp/run/admin.sock (owner only; no port that could clash with another
installation). A path longer than a socket allows falls back to
localhost:<Port + 10000>; AdminAddress sets it explicitly. stop falls back
to SIGTERM when the API does not answer; graceful falls back to a restart.
graceful loads the new configuration without dropping a connection; the PHP
threads restart with the new interpreter settings. Measured: a thread count
change under a request loop, 66 of 66 requests answered, the process kept.
Caddy keeps its state under var/vc/frankenphp/caddy/ (XDG_DATA_HOME,
XDG_CONFIG_HOME), not in the home directory of the service user.
Logs
Everything Velocity (vc) writes is below var/vc/, one directory per
server, so what belongs together is kept together:
var/vc/
├── qbix/
│ ├── etc/ the configuration tree (/etc/vc with root)
│ ├── lib/ what is known about each site (/var/lib/vc with root)
│ ├── log/ site-access.log, site-error.log, velocity-layout.log [LogSettings] Dir
│ └── run/ server.pid, console.log
├── frankenphp/
│ ├── bin/ the binary (exp:velocity install)
│ ├── caddy/ Caddy's state
│ ├── tls/ the self-signed certificate
│ ├── log/ access.log, error.log [FrankenPHPSettings] LogDir
│ └── run/ server.pid, console.log, Caddyfile, admin.sock
└── php/
├── log/ server.log [PHPServerSettings] LogFile
└── run/ server.pidstatus names each file. Caddy writes JSON, so FrankenPHP's access.log and
error.log (or AccessLog/ErrorLog) are its own — switching engines never
leaves one file in two formats. LogSettings Enabled and FileMode apply;
Format does not. Caches stay where they were ([CacheSettings] Dir,
var/tmp/precompress), and so does var/tmp/velocity-server.json, the Qbix
server's configuration before the vc layout, which older setups read.
Upgrading: until 2026-09 the logs were in var/log/qbix/ (Qbix server,
and FrankenPHP's frankenphp-access.log/frankenphp-error.log) and
var/tmp/velocity-php.log, the pid files, console logs, the Caddyfile and the
admin socket in var/tmp/, the Qbix configuration tree in var/vc/etc and
var/vc/lib. That tree is moved to var/vc/qbix/ on the next start, with
whatever was edited in it, when nothing is there yet; the other old files
stay where they are. A server started
before the upgrade is still found and stopped (FrankenPHP by its old
Caddyfile path, since its admin socket moved). An installation that sets
[LogSettings] Dir, PidFile or LogFile itself keeps what it set; a
script that reads var/tmp/velocity.pid reads
var/vc/qbix/run/server.pid now.
PHP's built-in web server
php bin/php/console exp:velocity start --engine=php --allow-root-userphp -S Host:Port -t DocumentRoot bin/php/velocity-router.php, with the PHP
that runs exp:velocity (or [PHPServerSettings] PHPBinary), its php.ini and
[PHPSettings] IniOptions[]. Nothing to install. Workers becomes
PHP_CLI_SERVER_WORKERS (Linux); the request log is the server's console,
var/vc/php/log/server.log — requests and PHP's errors in one file,
since php -S does not separate them.
It is a development server, as the PHP manual says: plain HTTP, no reload
(graceful restarts), static files without a cache lifetime. Keep Host at
127.0.0.1.
bin/php/velocity-router.php does what .htaccess_root does. It judges a
static path as it resolves: /design/standard/stylesheets/../../../settings/site.ini
starts like a stylesheet, and the built-in server would resolve the dots and
send the file. A resolved path outside the root, or outside the list, or a
script, is a 404.
Settings each engine ignores
start and status name every setting an installation set that the engine in
use does not act on — CacheSettings on FrankenPHP, ForkPerRequest and
KeepGlobals (nothing is kept between requests there), LogSettings Format,
the Qbix server's Brand* and dashboard settings, HTTPSSettings on the
built-in server. Shared settings: Host, Port, HTTPSPort, DocumentRoot,
Workers (FrankenPHP: threads; SpareWorkers more under load), StaticMaxAge,
HTTP2, HTTPSSettings, LogSettings, PHPSettings, EnginePhar,
ControlSettings StopTimeout.
What is served as a file
All three engines follow .htaccess_root: the asset directories it lists
(expVelocity::STATIC_PATHS — design and extension stylesheets, images and
scripts, var/*/storage/images, the public caches, icons, package previews,
favicon.ico, robots.txt) are sent as files; everything else goes to a
front controller — /api/ to index_rest.php, the admin tree menu to
index_treemenu.php (expVelocity::FRONT_CONTROLLERS), the rest to
index.php. No other .php file runs because its path was asked for
(expVelocity::ENTRY_SCRIPTS: index.php, index_rest.php), and a file
outside the list is not handed out: the originals in var/*/storage/original
reach a visitor only through content/download, which checks who is asking.
| frankenphp | php | qbix | |
|---|---|---|---|
| assets | file_server @static | router returns the file | Q.web.static.paths |
| scripts and dot paths below an asset directory | not path_regexp NEVER_STATIC → index.php | same list → index.php | not static (.php never is, dot paths are blocked) |
other .php by name | rewrite @front /index.php | router → index.php | Q.webserver.scripts |
/api/, tree menu | rewrite @rest, @treemenu | router | Q.webserver.frontControllers |
The three Qbix keys need a qbix-webserver that has them; an older one ignores
them, runs any .php file requested by name and sends every file with a
served extension — .htaccess is not read on its pooled path.
None of the three engines reads .htaccess. Copy it anyway
(./console exp:setup:htaccess --apply) when Apache may serve the same tree,
for example beside Velocity or during a move
(Setting up .htaccess and config.php from the console).
Symbolic links out of the document root
[ServerSettings] FollowSymlinks=disabled (the default) refuses a file that a
link inside the document root leads to outside of it: the qbix engine answers
403 (the server's own check, Q.webserver.followSymlinks), the php engine 404
(the router compares the resolved path with the root). A link placed by an
upload, an installer or a shared asset tree would otherwise hand out whatever
it points at.
enabled is for installations that link on purpose — extensions linked in from
a shared checkout, var/storage on another disk. Velocity then writes
followSymlinks: true into the Qbix config and passes
EXP_VELOCITY_FOLLOW_SYMLINKS=1 to the router. A path with .. segments is
refused either way, and neither engine serves a .php file as a file. Caddy's
file server always follows links; on FrankenPHP status names
FollowSymlinks=disabled as a setting it does not act on.
Verbs that belong to the Qbix server: ssl, layout migrate,
site|conf|mod enable|disable refuse on the other engines; layout lists the
files the engine uses instead; cache clear succeeds with nothing to clear.
Setup > System information
- "Webserver" names the software (FrankenPHP with its and Caddy's version and the mode; PHP's built-in server with its workers).
- "Velocity: <engine> (<role>)" appears when a Velocity engine serves the
page: its role, whether it is the default and how the others are started,
its address and who can reach it, version, process, logs, the notes about
settings it does not use, and the views the server answers itself, each
linked, with who may open it (below). Other engines of the installation that
are running are named in one line. A server that answers on another port
than velocity.ini gives the engine (started by hand) is marked as such.
The box reads the process table once and sends its own server no health
check request (
expVelocity::status( false )), so it adds no wait to the page;exp:velocity statuskeeps the check. - "Phar App Engine" (the kernel archive,
EnginePhar) is shown for the qbix engine only. The archive also runs on the other engines and servers (EXP_ENGINE_PHAR), but is not described there. - "OPcache and APCu", for every server — Velocity or not, Apache, nginx
with php-fpm: enabled or why not, memory, entries, hit rate, restarts, and
the settings that decide whether an edited PHP file is picked up
(
validate_timestamps,revalidate_freq).apc.enable_cliis listed only for command-line servers (qbix, php), which depend on it. The figures are the answering server process's own. Shown as bars (memory, fill level, hit rate; green, amber from 75 %, red from 90 % — for the hit rate the other way round), with a pending reset named. - Setup > Caches empties both, in a section "PHP caches of this server
process" below "Clear all caches" (which does not include them), for users
with
setup/managecache, as a POST checked by the form token:- Reset OPcache:
opcache_reset()under php-fpm, FrankenPHP andphp -S— the next request compiles from scratch. Under a command-line server (qbix) OPcache never sees the process idle, so a reset would stay pending until the server restarts; there every cached script is invalidated instead (opcache_invalidate()), which takes effect at once. Only a restart gives that memory back. - Empty APCu:
apcu_clear_cache(); under qbix that includes the memory tier of its response cache.
- Reset OPcache:
- Without Velocity, "Webserver" names the server from
SERVER_SOFTWARE(for example nginx 1.14.1, SAPI fpm-fcgi) and there is no Velocity box.
Tokens are never put into links; the page itself is for administrators only.
Views and who may open them
| Engine | View | What | Who may open it |
|---|---|---|---|
| qbix | /Q/dashboard | live dashboard | this machine; elsewhere with [DashboardSettings] Token. With a panel password set, only the panel login opens it, from this machine too — the Token does not (Qbix Dashboard::handle()) |
| qbix | /Q/stats, /Q/metrics, full /Q/health | dashboard figures, Prometheus, health report | this machine; elsewhere with the Token or the panel login; or everyone with Remote=enabled and no token |
| qbix | /Q/phpinfo | phpinfo() including the process environment | this machine only; once a Token or panel password exists, only with it, from this machine too |
| qbix | /Q/panel | control panel | this machine only, then the panel password (the first visit sets it) |
| qbix | /Q/docs, /Q/cluster/status, /Q/health status | documentation, cluster state, "ok" | everyone |
| frankenphp | /Q/health | empty 200 | everyone |
| frankenphp | admin API | var/vc/frankenphp/run/admin.sock | not a web page: the user running the server only |
| php | /Q/health | empty 200 | everyone |
The Qbix rules are the server's own (Q_WebServer::adminAllowed(), v0.0.4.27);
the page reads the running server's token, remote setting and panel password
rather than assuming velocity.ini's.
PHP 8.0 and Red Hat Enterprise Linux
The qbix engine runs on PHP 8.0 to 8.5 from release 0.0.4.54 (20 October 2026); its composer.json requires
"php": ">=8.0" (it was >=8.1), and nothing extra is needed on 8.0, no polyfill package and no extension.
Exponential's composer.json requires the engine at ~0.0.4.57, so composer install on PHP 8.0 installs it too.
- RHEL 9, AlmaLinux 9, Rocky Linux 9: the default AppStream PHP 8.0 works, with the el9 rpm
(
exponential-velocity-<release>-1.el9.noarch.rpm, which depends onphp-cli >= 8.0) or with Composer; no module stream has to be enabled, and a newer one still works. - RHEL 8: the default PHP is 7.2. Run
dnf module enable php:8.0and use the phar or Composer; there is no el8 rpm.
What differs on PHP 8.0, and only this:
| On PHP 8.0 | |
|---|---|
| AVIF image output | needs PHP 8.1; the negotiation stops at WebP |
| The dashboard's per-request memory peak | needs PHP 8.2; the column is empty |
$_FILES full_path | absent, as in PHP 8.0 itself |
A class declared with eval() that has static properties | its worker is replaced after that request (correct; it costs a fork) |
The engine handles the rest itself: it reads declared static defaults from the source (PHP 8.0's getDefaultValue()
returns the current value), calls setAccessible() on 8.0 for private and protected statics, and clears the stat
cache for the dashboard's log tail. The zygote runs on 8.0. Extensions, systemd, SELinux and verification are in the
engine's PHP 8.0 and Red Hat Enterprise Linux
page, its minimums in requirements,
the release in its changelog. For the
kernel on PHP 8.0, see PHP 8.0 support.
The engine itself: releases, upgrade notes and feature pages
This page is about driving the engine from Exponential. What the engine is, how it came to be and what each of its releases changed is documented separately:
| You want | Read |
|---|---|
| What changed in a release | Changelog of the Velocity engine |
| What to change when you upgrade the engine (0.0.4.27 to 0.0.4.42) | Velocity engine upgrade notes |
| The story month by month | Velocity chronicle |
| The web server, control panel, cache, HTTPS, shell, packages | web server, control panel, response cache, HTTPS, Q shell, packages and binaries |
| Every setting of the engine | Engine settings, worker pool, HTTP/2 and security |
The access rules in "Views and who may open them" above are the ones exp:velocity writes into the engine's configuration; they can be stricter than the engine's own defaults described in the control panel page.
Demo mode under Velocity. Velocity's workers inherit the environment of the server process, and the server exports
what /etc/vc/envvars sets into it when it starts, as Apache's /etc/apache2/envvars is sourced before apache2.
That file is shared by the server of every installation on the host, so demo mode never goes there: a worker refuses
an EXP_DEMO_MODE the engine took from it and logs demo/refused in security.log. For a public demo, add
export EXP_DEMO_MODE=1 to the demo's own site envvars, /etc/vc/sites-available/<site>.envvars, which
exp:velocity start reads for this installation's server only; restarts and deploys from any shell keep it, and
exp:velocity layout lists it as site envvars. The environment that starts this installation's server (its unit's
Environment=, see velocity-demo-mode.conf) works too. Neither
file can set PATH, LD_*, PHPRC and the other loader and shell variables (on-disk layout).
Demo mode.