Skip to content
docuccino

Open source · MIT

API documentation for Laravel that documents change and provenance, not just endpoints.

Docuccino reads the code you already wrote — controllers, form requests, resources, exception handling — and compiles it into a deterministic, identity-carrying document. OpenAPI comes out of that. So do a semantic diff your build can fail on, and an answer to why is it documented this way.

Requires
PHP 8.3+
Supports
Laravel 12 & 13
Emits
OpenAPI 3.2 / 3.1 / 3.0
~/invoices-api
$ composer require docuccino/laravel
$ composer require --dev docuccino/inference-phpstan
$ php artisan docuccino:install

   Published config/docuccino.php and docuccino.yaml
   Matched 38 routes against the default api/* pattern
   Analysis engine installed
   Wrote docs/openapi.json

   Your viewer is live at /docs/api

Analysis is a build-time job. Your app is never executed to produce a document, and the engine never has to run on a production host.

How it works

Your application in. A document that knows its own history out.

Most generators read a spec file you maintain, or annotations you remember to write. Docuccino reads the application, and keeps hold of where every detail came from.

  1. 01

    It reads your application

    Routes, controllers, form requests, resources, DTOs, enums and exception handling — analysed with an embedded PHPStan and Larastan engine at build time. Your code is never executed to produce a document.

  2. 02

    It compiles to the UIR

    An OpenAPI-3.2-shaped document where every operation, schema and parameter carries a stable identity and a record of the file, line and layer that produced it.

  3. 03

    It emits what you need

    OpenAPI 3.2, 3.1 or 3.0, a bundled interactive viewer at /docs/api, a semantic diff your build can fail on, and a machine-readable surface for the agents now reading your docs.

Precedence, lowest to highest

fallback inference integration docblock attribute overlay config

Higher layers win field by field — so one attribute overrides one inferred description, and everything else it got right stays.

What you get

Accurate on the first export, not after a week of annotating.

Everything below happens with no configuration beyond pointing Docuccino at your routes. Annotations exist for the things your code genuinely cannot say.

Deep type inference

Request and response shapes come from your real types — array shapes, generics and conditional returns included. The type hints and PHPStan extensions you already have make your docs better for free.

What it reads

Query parameters, for free

Filters, sorts, includes and pagination are recovered by constant-folding through helper methods several calls deep. No hand-written parameter lists to keep in step with the builder.

Query builders

Error responses, automatically

Docuccino reads the exception handling you actually wrote — render callbacks, exception render(), Responsable::toResponse() — with the thrown type narrowed, so 422, 404 and your own error shapes document themselves.

Errors

Byte-deterministic output

The same code on the same version produces a byte-identical document. No timestamps, no absolute paths, no shuffling — commit the artifact and your day-to-day diffs stay about the API.

How it works

A diff you can gate CI on

docuccino:diff --enforce compares two artifacts over stable identities and applies a versioning policy. Ship a breaking change without the version bump to match and the build fails.

Commands

A format you can build on

The UIR is a documented format with a published JSON Schema. Export it, validate it, read every field back to the line that produced it — the same document the emitters, the diff and the viewer all run on.

The UIR

Code in, docs out

This controller. No annotations. That document.

A list endpoint built the way Laravel applications are actually built — a query builder behind a helper method, a resource, a form request. Here is what comes out.

InvoiceController.php Your code
// app/Http/Controllers/Api/InvoiceController.php
final class InvoiceController {
    public function index(IndexInvoices $request): ResourceCollection
    {
        $invoices = QueryBuilder::for(Invoice::class)
            ->allowedFilters($this->filters())   // folded through
            ->allowedSorts(['issued_at', 'total'])
            ->paginate();

        return InvoiceResource::collection($invoices);
    }

    /** @return list<string> */
    private function filters(): array
    {
        return ['status', 'customer_id', 'issued_at'];
    }
}
docs/openapi.json Generated
"/api/invoices": {
  "get": {
    "operationId": "invoices.index",
    "summary": "List invoices",
    "parameters": [
      { "name": "filter[status]",      "in": "query" },
      { "name": "filter[customer_id]", "in": "query" },
      { "name": "filter[issued_at]",   "in": "query" },
      { "name": "sort", "schema": { "enum": ["issued_at", "-issued_at",
                                     "total", "-total"] } },
      { "name": "page", "schema": { "type": "integer", "minimum": 1 } }
    ],
    "responses": {
      "200": { "$ref": "#/.../InvoiceCollection" },
      "401": { "$ref": "#/.../Unauthenticated" },
      "422": { "$ref": "#/.../ValidationError" }
    }
  }
}

// Nothing on the left was annotated. Pagination appears because
// the call graph reaches a paginating terminal; 401 and 422 come
// from the application's own exception handling.

Ask any field where it came from

Because every node carries its own provenance, docuccino:explain can read the trail back: which layer won, what it shadowed, the file and line behind it, and exactly what to change to override it.

Command reference
explain
$ php artisan docuccino:explain invoices.index --field=responses.200

  GET /api/invoices    invoices.index

  responses.200.content.application/json.schema
    ✔ won       inference   InvoiceResource.php:22
                          InvoiceResource::toArray()
    ✖ shadowed  fallback    generic 200 skeleton

  responses.200.description
    ✔ won       docblock    InvoiceController.php:14

   To change it: #[Response(200, ...)] on InvoiceController::index(),
    or an overlay entry — an overlay outranks every layer above.
ci
$ php artisan docuccino:diff docs/openapi.json build/openapi.json --enforce

  2 breaking  ·  5 additive  ·  1 cosmetic

  BREAKING  invoices.update    request property `currency` became required
  BREAKING  invoices.destroy   operation removed
  ADDITIVE  invoices.index     query parameter `filter[customer_id]` added
  ADDITIVE  credits.store      new operation
  COSMETIC  invoices.show      description reworded

  Policy semver  ·  1.4.2  2.0.0 required, found 1.5.0

  FAILED  A breaking change needs a major version bump.
$ # exit code 1 — the build stops here, not in your consumer's integration.

Break the build, not the integration

The diff compares two artifacts over stable identities, so a renamed route or a reordered file is not mistaken for a change. Pick a versioning policy — semver, date, or none — and --enforce makes it a required check.

How it works

The surface

A handful of commands, and one config file.

Configuration lives in a single published file, organised around named documents. Everything else is a command you run in development or in CI.

  • docuccino:install First run. Publishes config, reports how many of your routes each document matches, and offers a first export.
  • docuccino:export Builds the document and writes it where you point it — OpenAPI or raw UIR.
  • docuccino:diff Semantic diff between two artifacts, with --enforce to apply your versioning policy in CI.
  • docuccino:explain Reads the provenance trail back for one operation: which layer won each field, and what it shadowed.
  • docuccino:validate Checks an artifact against the published UIR JSON Schema.
  • docuccino:coverage Merges what your test suite exercised and gates on undocumented or unverified endpoints.
  • docuccino:watch Rebuilds as you edit, so the viewer keeps up with the code.
  • docuccino:cache Warms the analysis cache ahead of a build; docuccino:clear throws it away.

Three commands, and your API documents itself.

Install it into an existing Laravel application and export. If the result is thin, the troubleshooting guide starts from what you can see and works back to the fix.