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.
When there are operations to analyze, the build forks workers to share them — one for each CPU the
process may use, up to eight, and never more than one for every eight uncached operations. Each worker
inherits the analyzer the build already booted, builds whole operations and hands them back through the
fragment store, so the document is byte-identical to the one a single process writes. There’s nothing to
turn on: every console build with the pcntl extension does it, whether or not cache.enabled is true. A
console build is a process started to run the command — Artisan::call() from a queued job, a controller or
another command builds inside the process it was called from, which serves other work, and never forks it.
The rest of the build is shared too. The lints run in a worker of their own while the document is finished, and an export’s schema check and each target’s emit get one each, so an export of four targets waits for the slowest of them rather than all of them in turn. The files are still written in the order the targets are listed, and the output is the same.
engine: workers: 2 # 1 builds everything in one processSet engine.workers only to pin the count, on a machine where the CPUs aren’t all yours to use; 0 or
1 turns forking off. Each worker runs under the same memory_limit as the build, so in a container with a
memory limit no more workers start than fit in it at that ceiling each — and none beside the build where
memory_limit is -1. Windows always builds in one process, and so does a viewer request that builds the
document.
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, viewer, info and api_version |
Every operation in that document |
Your app’s boot-time registrations: 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.Five 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.engine.workers. How many processes share a build can’t change a documented byte either.documents.<key>.export and documents.<key>.viewer. Export targets say where artifacts are
written, never what’s in them, and viewer is wiring for the docs page rather than anything the
document says. Adding a second target, renaming an output file or moving the viewer route writes
new files from the cache you already have, rather than re-analyzing the app.documents.<key>.info and documents.<key>.api_version. Both shape the document, but they
shape it after every operation is built — the title and version land in the emitted info object,
and api_version drives the version changes and the X-Api-Version header. So two documents that
differ only in those two — which is exactly what several live API
versions look like — build from the same stored fragments.
Twenty versions of a 400-route API analyze 400 operations between them, not 8,000.Two documents share fragments only when everything else about them matches, and two things opt a document out of sharing: reading recorded examples, which are filed per document, and registering an extension of your own, which Docuccino can’t vouch for. Either one costs a rebuild per document and nothing else.
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.