Skip to content

Speeding up builds

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.

docuccino.yaml
cache:
enabled: true

That’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.

docuccino.yaml
engine:
workers: 2 # 1 builds everything in one process

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

  • “A file the analysis read” means every file, however deep. Docuccino records the files an action’s analysis touched and re-hashes each one on the next build. Edit a DTO three calls down from the controller and the operation that returns it rebuilds. Files are compared by content, not timestamp, so touch or a branch switch that restores the same bytes costs nothing — while a dependency that was deleted invalidates immediately.
  • Boot-time registrations count because Docuccino reads them. A rate limiter name or a render callback shapes the document without living in any file the analysis opened, so those facts are folded into the key directly.

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.
  • Your narrative content pages. Fragments never read them. A typo fix in a Markdown page re-assembles the document without re-analyzing a single route.

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:

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

  2. 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:

Terminal window
time php artisan docuccino:export # cold
time php artisan docuccino:export # warm

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

Terminal window
php artisan docuccino:watch

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

Terminal window
php artisan docuccino:clear --fragments

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