Skip to the content
Documentation
Help Start
Reading in:
  • German: 9,075 of 9,075 pages
About the help in other languages
All help pages

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…

Download as PDF

Address: https://doc.exponential.earth/exphelp/viewer/bc/6.0/velocity-engines.md

On this page

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 changedcomposer 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.
PHPVelocity 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 changeThe 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 checkphp bin/php/console exp:velocity status reports the engine package and its version.
What to doNothing 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 changedvelocity.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 affectedEvery 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 checkphp bin/php/console exp:velocity status and php bin/php/console exp:velocity deploy --dry-run --allow-root-user
How to fixSet 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 alternative

Engine 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.

qbixfrankenphpphp
Rolerecommended: every stage, development to productionproduction-ready alternativedevelopment (shipped default)
Port (shipped)8088 ([ServerSettings])8089, HTTPS 84448087
Whatthe bundled Qbix server, a PHP processFrankenPHP: Caddy with PHP built in, one binaryphp -S, PHP's own server
Where it comes fromComposer (se7enxweb/exponential-velocity, formerly se7enxweb/qbix-webserver)downloaded by exp:velocity install, SHA-256 checkedthe PHP that runs exp:velocity
PHPthe machine'sthe binary's own (official 1.12.x: PHP 8.5)the machine's
Requestspersistent or per-request workersclassic mode: a thread pool, clean state per requestWorkers processes, clean state per request
Response cacheyes ([CacheSettings])not yetno
TLS / HTTP/2yesyesno
gracefulre-exec, socket keptconfiguration reload, no dropped connectionrestart
Config tree /etc/vcyesno — a generated Caddyfileno — 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
#StepBy hand
1extension autoloadsphp bin/php/ezpgenerateautoloads.php -e
2kernel autoloads, only with --kernelphp bin/php/ezpgenerateautoloads.php -k, restricted to kernel/ and lib/
3INI caches, and the compiled kernel service container (cache item kernelservices, kernel services), when settings changed or with --kernelphp bin/php/ezcache.php --clear-tag=ini (./console exp:services:compile for the container alone)
4template, 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
5engine archive, when Velocity runs from onephp bin/php/phar.php build: rebuilt only when a file in kernel/, lib/ or autoload/ changed, every file parsed first
6PHP-FPM: wait until its OPcache has revalidated the changed files (no reload), or reload it when that cannot be relied onsleep <revalidate_freq + 1>, or systemctl reload <[DeploySettings] PhpFpmService>
7restart Velocity, when it is runningexp:velocity restart
8content, 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
9Velocity's response cache, with step 8exp:velocity cache clear
10warm-up: [DeploySettings] WarmUpURL[] requested once each by a background processcurl 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 deployCleared
settingsINI caches, template-override, design_base, the caches of rendered output (steps 8 and 9)
templates or other design filestemplate, template-override, design_base, the caches of rendered output
translationstranslation, the caches of rendered output
template engine or operator registration codetemplate (compiled templates)
only PHP classesnothing 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.2s

This 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-fpm

auto 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 before

EXP_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 removed

Every 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 stop and kill write stopped.json beside the pid file (var/vc/qbix/run/), and start, restart and graceful remove it;
  • no restart is in progress. The detached restart writes restart-in-progress.json and removes it when its start has finished. A marker whose process is gone, or that is older than RestartTimeout, no longer counts;
  • the optional LockFile is not held (a deploy queue). If the lock is held for longer than RestartTimeout while the server is down, it no longer counts either;
  • it is not backing off. After three failed starts in a row, it waits Backoff seconds 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=12360

The 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:

EventWhenReasons
system.velocity.watchdog.startit started the engine (success), or the start failed (failed, warning)down, restart-stale
system.velocity.watchdog.backoffit did not try, three starts in a row having failedbackoff
system.velocity.watchdog.skipit left a down engine aloneintentionally-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/system

install 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 earlierEngine 0.0.4.41 and later
the serverqbixserver.phpsbin/qbixserver.php; qbixserver.php forwards to it
controlqbixctl.phpsbin/qbixctl.php; qbixctl.php forwards to it
consoleqbixconsole.phpsbin/qbixconsole.php; qbixconsole.php forwards to it
shellqshell.phpbin/qshell.php; qshell.php forwards to it
archivebin/qbixserver.pharsbin/qbixserver.phar; bin/qbixserver.phar is the same file
  • [ServerSettings] ScriptPath keeps 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, so ps, pkill -f patterns and monitoring that look for it keep matching. Setting it to .../sbin/qbixserver.php works 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, restart and status count a server of this installation whichever of the two paths it runs from: one started by exp:velocity start runs ScriptPath, one started by the engine's own qbixctl start runs sbin/qbixserver.php (expVelocity::scriptPaths()).
  • exp:velocity ctl, ssl and ext run the engine's sbin/qbixctl.php, or qbixctl.php with an older engine (expVelocity::engineFile()), and cache clear loads the engine's control class from its src/, 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, ... and console.log, console.1.log, .... Instance 0 keeps the configured names, so Instances=1 (the default) is exactly the single server as before.
  • start, stop, kill and status reach every instance; status lists the parents. graceful reloads 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 instancefour instancesFrankenPHP
anonymous, 64 concurrent~2,950/s11,985/s, p95 10 ms4,153/s
signed in (the HTTP cache), 8 concurrent1,435/s3,991/s1,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 --pid is one of this installation's pid files, or, without one, whose --root is its document root -- never a shell, timeout or another wrapper that only mentions the script, never the engine's own --stop and --reload commands, never exp:velocity itself 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: zygote and 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:

SettingEngine defaultMeaning
StreamTimeout300Seconds 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.
StreamShare0.25The 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.
RetryWithin10Seconds 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=8

Then ./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-user

The 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).

OptionEffect
install --checkre-hash the installed binary
install --forcedownload 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-digestfor 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:

VariantAssetWhen
gnu (default)frankenphp-linux-<arch>-gnuany distribution with glibc 2.17+; faster allocator than musl, loads .so extensions (oci8 …)
muslfrankenphp-linux-<arch>Alpine, or where nothing may be taken from the system
mimallocfrankenphp-linux-x86_64-mimallocmusl 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 only

HTTPS=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.pid

status 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-user

php -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.

frankenphpphpqbix
assetsfile_server @staticrouter returns the fileQ.web.static.paths
scripts and dot paths below an asset directorynot path_regexp NEVER_STATIC → index.phpsame list → index.phpnot static (.php never is, dot paths are blocked)
other .php by namerewrite @front /index.phprouter → index.phpQ.webserver.scripts
/api/, tree menurewrite @rest, @treemenurouterQ.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).

[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 status keeps 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_cli is 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 and php -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.
  • 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

EngineViewWhatWho may open it
qbix/Q/dashboardlive dashboardthis 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/healthdashboard figures, Prometheus, health reportthis machine; elsewhere with the Token or the panel login; or everyone with Remote=enabled and no token
qbix/Q/phpinfophpinfo() including the process environmentthis machine only; once a Token or panel password exists, only with it, from this machine too
qbix/Q/panelcontrol panelthis machine only, then the panel password (the first visit sets it)
qbix/Q/docs, /Q/cluster/status, /Q/health statusdocumentation, cluster state, "ok"everyone
frankenphp/Q/healthempty 200everyone
frankenphpadmin APIvar/vc/frankenphp/run/admin.socknot a web page: the user running the server only
php/Q/healthempty 200everyone

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 on php-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.0 and 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 outputneeds PHP 8.1; the negotiation stops at WebP
The dashboard's per-request memory peakneeds PHP 8.2; the column is empty
$_FILES full_pathabsent, as in PHP 8.0 itself
A class declared with eval() that has static propertiesits 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 wantRead
What changed in a releaseChangelog 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 monthVelocity chronicle
The web server, control panel, cache, HTTPS, shell, packagesweb server, control panel, response cache, HTTPS, Q shell, packages and binaries
Every setting of the engineEngine 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.

Something wrong on this page? Report an error

Documentation license

This documentation is free documentation, licensed under the GNU Free Documentation License 1.3 or later.

Exponential Copyright © 1998-2026 Exponential Foundation and 7x