9 August 2026¶
3 changes:
pramnos initworks in a freshly created project again- Scaffolded Docker images ship the database CLI client
- A failed schema import is no longer silent
pramnos init works in a freshly created project again¶
app/app.php is now treated as an optional file when the kernel boots, so the
console front controller no longer fatals in a project that has not been scaffolded
yet — which is exactly the project init is meant to scaffold.
The problem¶
The documented way to start a new project is:
mkdir my-app && cd my-app
composer init -n
composer require mrpc/pramnosframework
php vendor/bin/pramnos init
The last line died before printing anything:
PHP Warning: require(/path/my-app/app/app.php): Failed to open stream: No such file or directory
in .../Pramnos/Application/Application.php on line 148
PHP Fatal error: Uncaught Error: Failed opening required '/path/my-app/app/app.php'
Pramnos\Console\Application::__construct() builds an internal
Application::getInstance() for every command (commands need it for the database,
the app namespace, and so on). That constructor did a bare
require APP_PATH . '/app.php' — but in a brand-new project app/app.php does not
exist yet, and creating it is init's whole job. A require failure is an
uncatchable fatal, so getInstance()'s try/catch could not soften it either: the
framework could not be bootstrapped into a fresh directory at all.
The fix¶
Reading the application configuration moved into a small, tolerant loader:
protected static function loadApplicationInfo($file)
{
if (!file_exists($file)) {
return array();
}
$info = require $file;
return (is_array($info) || is_object($info)) ? $info : array();
}
Both constructor branches (default app and named app) now go through it. A missing
config yields an empty $applicationInfo; a file that returns a scalar (a
half-written config, or one missing its return) degrades to the same empty array
instead of assigning a scalar that would break every later
$app->applicationInfo['…'] read. An existing file — including one returning an
array-like object — is returned exactly as before.
This is safe because every consumer already reads the individual keys defensively
(isset() / ??) with its own default — MakeCommandBase, for example, falls back
to the App namespace. For a scaffolded project nothing changes: the file exists
and is returned verbatim.
Result¶
php vendor/bin/pramnos list # works in an empty project
php vendor/bin/pramnos init # scaffolds, syncs dependencies, prints the summary
Tests¶
tests/Unit/Application/ApplicationInfoLoadingTest.php — missing file → [],
existing file → returned verbatim, object config → preserved, scalar return → [].
Why it started failing¶
The bare require dates back to 2020 (96604e22), but until recently it was never
reached in a fresh project. With no app/app.php, getInstance() fell back to
['namespace' => 'Pramnos'], which resolved to \Pramnos\Application — a
namespace, not a class. class_exists() said no, nothing was instantiated, and
getInstance() handed back null. The console application carried a null
internalApplication, init never touched it, and the scaffold ran fine.
f5891202 (make an app-specific Application subclass optional, 29 Jul 2026) gave
that resolution a fallback to the base kernel. Correct in itself — but it means the
kernel really is instantiated in a project that has no config yet, so the
constructor's require finally ran, and fataled.
The fix restores the fresh-project flow without giving up that fallback: the console now gets a working base kernel instead of a null, and a missing config is simply an empty config.
Compatibility¶
Only previously-fatal cases change behaviour, so no working setup is affected. The
trade-off worth knowing: a deployment that loses its app/app.php (or has a wrong
APP_PATH) no longer dies loudly — it boots with an empty config, meaning no
addons, no configured middleware and no features. That failure is now quiet rather
than immediate.
Scaffolded Docker images ship the database CLI client¶
pramnos init now installs postgresql-client or default-mysql-client — whichever
matches the project's database — into the generated Dockerfile, so a schema dump
import actually runs instead of silently doing nothing.
The silent no-op¶
TestEnvironment::setup() — called by every scaffolded project's
tests/bootstrap.php — imports an optional schema dump by shelling out to the
database's command-line client:
Note the redirect. If the client is not installed, the shell's "command not found"
goes to /dev/null, shell_exec() returns nothing, no exception is raised — and the
test database is simply left empty. Every later test then fails somewhere else, for
reasons that have nothing to do with the real cause.
Neither the generated image nor the framework's own dev image had ever installed
those clients (the PHP drivers — pdo_pgsql, pdo_mysql, mysqli — were always
there, and they are what everything else uses). The gap stayed invisible because the
tests covering the import branch asserted nothing at all: they wrapped the call in
try { … assertTrue(true); } catch (\Exception) { assertTrue(true); }, which passes
whatever happens.
What changed¶
- Generated projects:
scaffoldDocker()adds the client matching the selected engine —postgresql-clientfor postgresql/timescaledb,default-mysql-clientfor mysql. Only one of the two, so the image does not grow for nothing. It also makes./dockerbasha usable place to inspect the database by hand. - This repository's dev image: the same two packages, so the framework's own suite exercises the import for real.
- The tests that hid it: the assertion-free
test_real_setup_*trio is gone. In its place are tests that assert an observable effect — the dump now creates a probe table, and the test checks that the table exists in the freshly created database. A dump ofSELECT 1;(what they used before) leaves no trace at all, so it could never tell an import that ran from one that did nothing.
Rebuilding¶
Existing environments need one rebuild to pick the client up:
docker-compose build php-apache-environment # this repository
docker-compose build app # a scaffolded project
Until then the import tests skip with an explicit "the 'psql' client is not installed in this container" message rather than failing — the missing binary is an environment gap, not a regression in the code under test.
A failed schema import is no longer silent¶
TestEnvironment now checks the exit status of the psql / mysql import and
raises a RuntimeException carrying the client's own output, instead of discarding
every error and handing back an empty test database.
Before¶
Both import branches sent everything to /dev/null and ignored the exit status:
So a missing client binary, a syntax error in the dump, wrong credentials — all
looked identical to success. The database was created but never populated, and the
suite then failed much later, somewhere unrelated. Worse, psql exits 0 even when
individual statements fail, so the status alone would not have been enough anyway.
After¶
- Both commands drop the redirect and run through a new
TestEnvironment::runImport(), which captures stdout+stderr and the exit status. - Non-zero exit →
RuntimeExceptionincluding the client's diagnostics. - Status 127 gets its own message — the client binary is not installed, and the bare "psql: not found" would not explain the consequence.
- The PostgreSQL import gained
-v ON_ERROR_STOP=1, so a dump whose statements fail actually reports failure rather than exiting 0.
RuntimeException: Schema import failed: psql exited with status 3:
ERROR: relation "a_table_that_does_not_exist" does not exist
Compatibility¶
runCommand() is untouched, and a successful import behaves exactly as before. The
change is only visible where an import was already broken: what used to be a silent
empty database is now an exception at the point of failure. If a project relied on
the import quietly doing nothing (for example a dump that no longer applies), remove
the schema path from the TestEnvironment::setup() call instead.
Tests¶
TestEnvironmentTest — a dump that fails mid-file raises with psql's diagnostics in
the message, a missing binary is reported as such, any other non-zero status carries
status and output, and a zero status stays silent.