Skip to content

Compile templates at build time - #40

Open
koriym wants to merge 8 commits into
2.xfrom
compile-step
Open

Compile templates at build time#40
koriym wants to merge 8 commits into
2.xfrom
compile-step

Conversation

@koriym

@koriym koriym commented Aug 17, 2026

Copy link
Copy Markdown
Member

Qiq compiles a template on first render and writes the result, so a read-only tree or a packed
archive dies on the write. This compiles every template at build time instead, and the serve side
only reads.

  • QiqCompileStep implements BEAR\Sunday\Compile\CompileStepInterface, bound by engine name
  • QiqServeCompiler returns {buildDir}/qiq/{key} or throws TemplateNotCompiledException. It
    takes the directory from AbstractAppMeta::$buildDir instead of composing one, so it cannot
    drift from the runner that writes there
  • QiqErrorPageRenderer takes an injected Template; Template::new() used to write to
    sys_get_temp_dir() while serving, so a read-only filesystem killed the error page itself

QiqProdModule no longer takes a cache path: bear/package writes the artifacts and fixes their
location, so a path from the application could only disagree with the writer.

BREAKING: an application that installs QiqProdModule must run the compile step before it
serves, must drop the constructor argument, and must compile into an empty var/build rather than
on top of the old one. A Meta baked into DI scripts from before bear/app-meta 1.13 has no
$buildDir, and it is read while the container is built, so a leftover build stops the application
at boot instead of losing a single template. PHP 8.2+, from 8.1.

Verified on a renamed, chmod -R a-w tree: page and error page both render and
sys_get_temp_dir()/qiq stays absent. The tests run the caller side of CompileStepInterface and
let rendering carry the assertion, so /var/build/ appears in neither the module nor the tests.

Three items to settle before tagging:

  • bear/app-meta is pinned to dev-build-dir as 1.13.0; back to ^1.13 once 1.13.0 is tagged
  • bear/sunday is ^1.8@dev; ^1.9 once 1.9.0 ships
  • the bear/package conflict floor is a guess until a release runs the compile steps

Templates inside a phar remain unsupported: Qiq's Catalog::split() reads phar: as a collection
name.

koriym added 2 commits August 17, 2026 15:31
Qiq compiled templates during the request: Catalog::getCompiled() calls
QiqCompiler::compile(), which mirrors the absolute source path under
sys_get_temp_dir()/qiq and rewrites it whenever the mtime moves. A prod
deployment on a read-only filesystem cannot serve at all, and the cache key
stops matching as soon as the tree is deployed to another path.

QiqCompileStep implements BEAR\Sunday\Compile\CompileStepInterface and does
the whole catalog in the build phase; Compiler is bound to QiqServeCompiler,
which only ever reads. Keys come from TemplateKey: the source path minus the
qiq_paths root it lives under, longest match first, no realpath(). A source
under no root raises TemplateOutsideRootException instead of quietly picking
another key.

Catalog::compileAll() needed two things guarded. Its enumeration overwrites a
duplicate key with the last root, while Catalog::source() serves the first
readable one, so QiqBuildCompiler skips a key that already exists and the step
empties its own directory first. And RecursiveDirectoryIterator throws on a
root that does not exist, which is the state of every skeleton before its
first template, so missing roots are dropped from the spec list.

QiqErrorPageRenderer went through Template::new(), a static factory that
builds its own QiqCompiler(null): the error page kept writing during serve and
would have died on a read-only filesystem, hiding the original error. It now
renders with the injected Template.

Templates inside a phar are still out of reach, and not because of the key
rule: Catalog::split() cuts a template root at its first colon, so the
"phar" scheme becomes the collection name and Catalog::source() never resolves
one. That is a Qiq-level limit.

This breaks existing prod modules. QiqProdModule takes over the Compiler
binding, so an application that installs it and does not run the compile step
raises TemplateNotCompiledException on its first render. Hpplus.Eclat and
Hpplus.Spur both install it with an explicit cache path, which keeps resolving
but is no longer written to by anything.

bear/sunday is on ^1.8@dev until CompileStepInterface and #[BuildDir] are
released; a plain 1.x-dev fails `composer validate --strict`, which the
coding-standards workflow runs. The argument-less QiqProdModule is unusable
until bear/package binds #[BuildDir]; the conflict entry keeps a bear/package
without that binding from resolving, where Qiq would fail with Unbound at
serve time.
The build directory drops the context from its path, so #[BuildDir] and the
module that bound it go away with it: bear/sunday deletes the qualifier, and
the read side derives the directory from AbstractAppMeta instead. A compiled
artifact has no context to carry - a phar does not know which context built it
and has no use for the answer.

QiqProdModule loses its cache path argument. The writer is bear/package, which
computes {appDir}/var/build/{step name}; a path the application passed could
only ever disagree with it. Applications installing the module drop the
argument.

The cost of the fixed path: one tree holds one compile. A second compile of
another context overwrites the first, so two read-only deployments need two
artifacts.
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: acfe2b3a-24a0-4ced-a175-1adf0cd07f0d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

koriym added 2 commits August 17, 2026 19:13
The runner writes {appDir}/var/build/{context}/{binding key} and the read side
looked in {appDir}/var/build/{binding key}, so no artifact was ever found. It
did not fail at serve time either: the preload worker boots the application, so
bin/compile.php exited 255 and the build never finished.

AbstractAppMeta::$buildDir carries the whole path, so QiqServeCompiler stops
computing one. The BUILD_DIR constant and the concatenation onto appDir go away
with it. A module that does not build the path cannot drift from the runner
again, which is how the context went missing in the first place.

The unit tests were green throughout that mismatch, because they built the
expected directory the same wrong way the module did. Correcting one made the
other fail, so there was nothing to notice. They now run the caller side of
CompileStepInterface - mkdir {$meta->buildDir}/{binding key} for every bound
step, then invoke - and let rendering be the assertion. The directory is named
once, from the Meta the container holds and the key the Map yields. Deriving it
from appDir again fails three of the four tests.

FakeAppMeta puts a context segment in its build directory for the same reason:
without one, appDir arithmetic and $buildDir agree and the regression hides.

Rendering goes through ResourceObject::toString() rather than the cast, since
__toString() catches Throwable and trades it for a warning and an empty string.
^1.13 resolves to nothing installable: 1.13.0 is not tagged, and the 1.x branch
that satisfies the range does not carry the property, so every job died in
composer install. The inline alias installs the branch under test, which is what
makes the suite mean anything here.

Both dependency pins - this one and bear/sunday ^1.8@dev - come off when the
releases exist. bear/app-meta goes back to ^1.13.
A Meta baked into DI scripts from before bear/app-meta 1.13 has no $buildDir,
and QiqServeCompiler reads it in its constructor, so the failure lands while the
container is built. Resolving AppInterface reaches Compiler through
RenderInterface, QiqRenderer, Template and Catalog, which means every request
stops, not the ones that render a template. Measured on a Meta serialized
without the property: unserialize succeeds, the constructor throws Error.
koriym added 2 commits August 20, 2026 04:50
1.13.0 is tagged, so the dev-build-dir pin can go.
@koriym

koriym commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Integration test against merged BEAR.Package 1.x

A fixture application on bear/package 6b02c3f (1.x head, with #501 and #507 merged) plus this branch. Four steps: compile, serve, pack, boot the archive with the build tree deleted.

step result
compile pass — the real CompileSteps runner wrote var/build/prod-html-app/qiq/Page/Index.php, {{h $greeting}} compiled to <?= $this->h($greeting) ?>
serve pass200 / Qiq says: Hello Qapp, and the step artifact's mtime is unchanged, so serving only reads
serve with the artifact removed passTemplateNotCompiledException(.../src/templates/Page/Index.php)
pack pass — 7.3MB, 2238 files; src/templates/Page/Index.php and var/build/prod-html-app/qiq/Page/Index.php both packed
boot the archive failQiq\Exception\FileNotFound

Two blockers, both on the source lookup

BEAR.Package hands the right values in. Measured inside the archive:

appDir    phar:///private/tmp/qship2.phar
buildDir  phar:///private/tmp/qship2.phar/var/build/prod-html-app
tmpDir    /private/tmp/qwrite2/MyVendor/Qapp/prod-html-app/tmp
qiq_paths /private/tmp/qapp2/src/templates          <- the build machine

AbstractAppMeta::__wakeup() re-points appDir and buildDir into the archive, and tmpDir correctly stays at the write directory. QiqServeCompiler reads $meta->buildDir, so the compiled side is portable. Only qiq_paths is not.

1. QiqModule::configure() freezes the path. toInstance([$this->templateDir]) is evaluated when the container is compiled, so the absolute path of the build machine is written into the DI script. __DIR__ in the installing module does not help: it is evaluated at compile time too. A provider reading AbstractAppMeta::$appDir at injection time would follow the archive, as Madapaja.TwigModule's AppPathProvider does.

2. A provider is not enough: phar:// is unparseable by Qiq's collection syntax. With qiq_paths bound to a provider returning {appDir}/src/templates, the archive still fails, and the reported paths are now empty:

Paths: Array
(
)

Qiq\Catalog::split() reads a collection:path spec, so a stream URI splits on its scheme:

spec       phar:///private/tmp/qship3.phar/src/templates
strpos(':') 4
collection phar
path       ///private/tmp/qship3.phar/src/templates

__DEFAULT__ ends up empty. BEAR\QiqModule\TemplateKey::split() carries the same logic — its own docblock says it mirrors Catalog::split() — so the module would need the same fix even if upstream took one. The existing PHP_OS_FAMILY === 'Windows' ? 2 : 0 offset is the same class of problem for C:; a stream URI is the third case.

The shape underneath

Serving resolves the source before it reaches the compiler:

// Qiq\Catalog::getCompiled()
$source = $this->source($name);          // must be readable
if ($source === null) { throw new Exception\FileNotFound(...); }
$this->compiled[$name] = $this->compiler->compile($source);

So prod needs the source tree present and enumerable even though QiqServeCompiler only ever reads {buildDir}/qiq. A read-only prod that has its compiled templates should not need the sources at all — and that is what would make an archive work without touching Catalog::split().

Related: a fix for the stream-URI split was written during earlier work on this (Keep stream URIs out of collection names, on a local Qiq checkout) and was never pushed anywhere. It is gone; it would have to be written again.

Reproduction is a fixture of eight files; say the word and I will put it somewhere you can run it.

@koriym

koriym commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Measured: the archive boots with no upstream change and no unfreezing

Following the two blockers above — I tried the third option instead: let prod resolve a name, not a source.

QiqRenderer hands Qiq a name (Page/Index), and the step has already written {buildDir}/qiq/Page/Index.php. A name maps to that file directly. Catalog::getCompiled() is the seam:

// vendor/bear/qiq-module/src/QiqProdCatalog.php
final class QiqProdCatalog extends Catalog
{
    private string $compiledDir;

    public function __construct(
        AbstractAppMeta $meta,
        Compiler $compiler,
        #[Named('qiq_extension')] string $extension = '.php',
    ) {
        parent::__construct([], $extension, $compiler);
        $this->compiledDir = $meta->buildDir . '/' . QiqCompileStep::NAME;
    }

    public function has(string $name): bool
    {
        return is_file($this->file($name));
    }

    /** @throws TemplateNotCompiledException */
    public function getCompiled(string $name): string
    {
        $file = $this->file($name);
        if (! is_file($file)) {
            throw new TemplateNotCompiledException($name);
        }

        return $file;
    }

    /** A name is a Qiq spec, never a path: a colon in it separates the collection */
    private function file(string $name): string
    {
        return $this->compiledDir . '/' . str_replace(':', '/', $name) . $this->extension;
    }
}

and in QiqProdModule::configure(), one line beside the existing one:

$this->bind(Catalog::class)->to(QiqProdCatalog::class);

What that measured

Same fixture, bear/package 6b02c3f. Archive moved out of the tree, the tree deleted:

case result
boot the archive 200 / Qiq says: Hello Qapp
qiq_paths at that moment /private/tmp/qapp4/src/templates — still the frozen build path, and it no longer exists
upstream Qiq unchanged
QiqModule unchanged, still toInstance()

And the read-only contract holds, tested on the extracted tree:

case result
source templates deleted, compiled kept 200 / Qiq says: Hello Qapp — prod never reads the sources
compiled artifact deleted TemplateNotCompiledException(Page/Index)

So both blockers become moot rather than fixed: qiq_paths stops being load-bearing at serve time, and Catalog::split() never meets a phar:// path because prod hands it no paths at all.

What this replaces

QiqCompileStep already builds its own Catalog, QiqBuildCompiler and TemplateKey for the build:

$key = new TemplateKey($this->paths);
$compiler = new QiqBuildCompiler($stepDir, $key);
$catalog = new Catalog($this->specs($key), $this->extension, $compiler);

so paths and TemplateKey are build-time tools, where the paths are real and local. With QiqProdCatalog in place, QiqServeCompiler has no caller left in prod — Catalog::getCompiled() was its only one. Two classes for one job become one, and the seam sits where the name is resolved instead of where a source would be compiled.

Open for your judgment

  • str_replace(':', '/', $name) treats a colon in a name as the collection separator. That is safe for names in a way it is not for paths, but it is the line to review.
  • has() is overridden too; anything else on Catalog that reaches source() would need the same treatment. compileAll() is build-time, so it is out of the prod path.
  • Whether this lands as a Catalog subclass or as a different wiring is yours; the measurement is only that resolving by name is enough.

The upstream Catalog::split() bug is still real — a stream URI is not a collection, and TemplateKey::split() mirrors the same logic — but on this evidence it is not what blocks the archive, so it needs no PR to unblock this one.

@koriym

koriym commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Addendum: I wrote above that QiqServeCompiler "has no caller left in prod" as reasoning. Measured now, so it is not reasoning any more.

A trace written into QiqServeCompiler::compile(), on the extracted tree with QiqProdCatalog bound:

compile   exit 0
serve     200 / Qiq says: Hello Qapp
QiqServeCompiler::compile()   never called

The render succeeds, so the absence is not a failure upstream of the call. Catalog::getCompiled() was its only caller and the prod catalog overrides it.

Qiq\Catalog::getCompiled() resolves the source file with is_readable() before it calls the
compiler, so prod needed the template tree even though only {buildDir}/qiq is ever read.
QiqProdCatalog maps a template name straight onto its artifact, so an application that
ships no templates serves, and a phar:// appDir resolves like any other.

has() and getCompiled() are the only public Catalog methods that reach source(); both are
overridden. compileAll() iterates the paths instead, and runs at build time from a catalog
that has real ones.

QiqServeCompiler goes. With the catalog resolving by name its compile() is unreachable, it
duplicated TemplateNotCompiledException, and it was the last serve-time reader of the
qiq_paths value frozen into the DI script. Prod binds Qiq\Compiler\NonCompiler so that
getCompiler() reports a compiler that cannot write.

The name-to-artifact mapping lives in TemplateKey::ofName(), beside the source-to-key
direction the compile step uses, so the collection-to-subdirectory rule is stated once.

qiq_paths keeps its toInstance binding. Nothing reads it at serve time any more, and
QiqModule has no AbstractAppMeta, so a provider could only guess which prefix of a frozen
absolute path used to be the appDir.
@koriym

koriym commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Prod now resolves a template by name under the build directory, so an application that ships only {buildDir}/qiq serves.

Qiq\Catalog::getCompiled() resolves the source file with is_readable() before it calls the compiler, so prod needed the template tree even though only the build directory is ever read. QiqProdCatalog maps a name onto its artifact directly. has() and getCompiled() are the only public Catalog methods that reach the protected source(), and both are overridden; compileAll() iterates the paths instead and runs at build time from a catalog that has real ones.

Three decisions:

  • QiqServeCompiler is gone. With the catalog resolving by name its compile() is unreachable, it duplicated TemplateNotCompiledException, and it was the last serve-time reader of the qiq_paths value frozen into the DI script. Prod binds Qiq\Compiler\NonCompiler so getCompiler() reports a compiler that cannot write. TemplateKey is now build-time only.
  • The name-to-artifact mapping lives in TemplateKey::ofName(), beside the source-to-key direction the compile step uses, so the collection-to-subdirectory rule is stated once.
  • qiq_paths keeps its toInstance binding. Nothing reads it at serve time any more, and it is correct where it is read: on the build machine during the compile step, and in a dev tree that does not move. A provider cannot do better here — QiqModule has no AbstractAppMeta, so it would have to guess which prefix of a frozen absolute path used to be the appDir. Making it honest means QiqModule taking an appDir-relative path, which is a separate change to its public constructor.

New tests: QiqProdCatalogTest (resolve by name, missing artifact raises TemplateNotCompiledException, a collection: name resolves under its subdirectory) and QiqProdRenderTest::testRendersWithoutTheSourceTree, which compiles, deletes var/templates, and still renders — that test fails with Qiq\Exception\FileNotFound on the previous bindings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant