Same bytes, less work
A warm build is byte-identical to a cold one. Determinism is what makes an incremental build trustworthy in the first place.
Reading types is the expensive part of a build. By default Docuccino analyzes every route on every run, which is the right default for a one-shot export and the wrong one for the tenth rebuild of an afternoon.
The fragment cache fixes the second case. It stores each operation’s finished fragment and serves it back when nothing that shaped it has changed — no analysis, and if every route hits, no analyzer at all.
cache: enabled: trueThat’s the whole setup. Fragments are written to storage/docuccino/fragments as one small JSON file
per operation; set cache.path to move them. The output doesn’t change — a warm build emits the same
bytes as a cold one, which is what makes the cache safe to leave on.
Set DOCUCCINO_FRAGMENT_CACHE=true to turn it on for one run without editing the file — the only
other way in, and how docuccino:watch turns it on for
the builds it drives.
The type engine is built on the first question asked of it, and a fully warm build asks none. Every route answered from cache means PHPStan is never booted, never loads your codebase, and never compiles a container. That startup is a fixed cost paid before a single route is looked at — on a fully warm build it would be nearly all of the work.
What doesn’t change when the engine stays asleep: the emitted bytes, and the build’s report on the
state of inference. If the engine package isn’t installed, a warm build still warns
engine.not-installed — that verdict comes from reading your environment, not from asking the
analyzer.
A fragment is keyed on everything that could change its output, and checked against the files that produced it. Change any of the following and the affected operations are re-analyzed:
| Change | What rebuilds |
|---|---|
| A route’s method, URI, name, action or middleware | That route |
| A file the analysis actually read — controller, form request, resource, model, DTO, custom rule | Every operation that read it |
Anything under documents.<key> in your config, except export |
Every operation in that document |
Your app’s boot-time registrations: Relation::morphMap(), RateLimiter::for(), exception render callbacks, app.url |
Every operation |
Which engine resolved, whether it’s installed, and its engine config |
Every operation |
Your composer.lock — a package or analyzer upgrade |
Every operation |
| Docuccino’s own version | Everything |
Two of those rows deserve the detail:
touch or a branch switch that restores the same bytes costs nothing — while a
dependency that was deleted invalidates immediately.Three things deliberately stay out of the key, because rebuilding for them would cost far more than they’re worth:
engine.memory_limit (and --memory-limit). A process ceiling can’t change a documented byte,
so raising it doesn’t throw away your cache.documents.<key>.export. Export targets say where artifacts are written, never what’s in
them. Adding a second target or renaming an output file writes new files from the cache you already
have, rather than re-analyzing the app.Two savings stack, and they scale differently:
The analyzer boot — a fixed cost, paid once per build, and skipped entirely when every route is warm. This is the bigger of the two on a small app and it doesn’t grow with your route count.
Per-route analysis — proportional to how many operations the edit invalidated. Editing one controller action rebuilds its operations. Editing a base controller, a widely-used API resource or a shared DTO rebuilds everything that reads it. Upgrading a package rebuilds all of it.
So the honest answer to “how much faster?” is: it depends on your route count and on how much of the app your edit touches. Measure it on your own application rather than trusting a number from someone else’s:
time php artisan docuccino:export # coldtime php artisan docuccino:export # warmThe first run after turning the cache on is a normal full build — it has a store to fill.
If you’re exporting by hand after every edit, docuccino:watch
is the loop with the cache already turned on:
php artisan docuccino:watchIt builds once, then rebuilds whenever a file the build depends on changes — and the files it watches are exactly the ones this page describes, read back out of the fragment store rather than guessed from a pattern. An open viewer refreshes itself when the document changes.
The fragment cache is off by default, and that default is right for a build that runs once:
| Situation | Worth it? |
|---|---|
| Local iteration — export, read, tweak, export | Yes. This is what it’s for. |
docuccino:watch |
Yes, and you don’t have to do anything: watch turns it on for the builds it runs. |
| CI on every pull request, with the store restored between runs | Yes — see caching the build in CI. |
| A release or deploy pipeline that builds once on a fresh machine | No. Every lookup misses; you pay the bookkeeping and save nothing. |
| A one-off export you run twice a year | No. |
The cost when it doesn’t help is small but real: a hash per dependency file, and a directory of JSON that grows. Nothing evicts old entries — a route you deleted leaves its fragment behind forever — so empty the store occasionally on a long-lived machine.
php artisan docuccino:clear --fragmentsIt prints Cleared N cached operation fragment(s). and empties the store for every document —
fragments are shared across documents, so naming one still clears all of them. It works whether or not
cache.enabled is currently true, and docuccino:clear is the one command with no
enabled guard, so it runs even in an installation you’ve switched off.
Prefer it to deleting storage/docuccino/fragments by hand: the command leaves the directory and its
.gitignore in place, and cleans up temp files a crashed write left behind. Full signature in the
commands reference.
Everything Docuccino writes for its own use lives under storage/docuccino: the fragment cache in
fragments/, and the analyzer’s compiled container and scratch files beside it. Into each directory
it creates, Docuccino drops a .gitignore containing * and !.gitignore — the same trick Laravel
ships inside storage/ — so none of it ever turns up in git status. A .gitignore you wrote there
yourself is never overwritten.
The exported spec is the deliberate exception. export.path is a reviewable artifact meant to be
committed, so it is never ignored on your behalf — see
what to commit.
Same bytes, less work
A warm build is byte-identical to a cold one. Determinism is what makes an incremental build trustworthy in the first place.
Sound by construction
A fragment that can’t prove it’s current is discarded, not served. A stale cache costs you a rebuild — never a wrong document.
enabled, store and path in the
reference.