Skip to content

Docuccino vs Scramble

Scramble is an excellent, popular API documentation generator, and if it’s working well for you, that’s a great place to be. The two tools share a core idea — documentation generated from your code, not hand-written annotations — so if you’ve been happy with Scramble’s approach, Docuccino will feel familiar. This page covers what’s different, what you’d gain, what you’d give up, and exactly how to move your annotations, config and extensions across.

Both tools read your code. Where they diverge:

Scramble Docuccino
Primary artifact An OpenAPI document An OpenAPI-3.2-shaped document carrying the Docuccino extension, emitted as OpenAPI 3.2, 3.1 or 3.0 (JSON or YAML), or a Postman collection
Analysis engine Its own type inference over your code An embedded PHPStan/Larastan engine, installed as a dev dependency
Production install Ships with the analyzer Adapter only — no static-analysis toolchain in vendor/
Interactive viewer Yes Yes, bundled Scalar
Output stability Regenerated on demand Byte-stable for identical code — built to be committed and reviewed
Contract checking — docuccino:diff over stable node identities, with --enforce gating a version policy in CI

That last row is the reason most teams look at Docuccino. Because output is deterministic, the spec becomes an artifact you commit; because every node carries a stable identity, Docuccino can tell a rename apart from a removal and fail a pull request that breaks the contract without the version bump your policy demands. See docuccino:diff.

Both tools generate from your code, and the overlap is large and healthy. The tables below break it down capability by capability, with the Scramble core package and the Scramble PRO add-on listed separately so you can see where a capability lives. ✓ means built-in support; — means not supported. Everything in the Docuccino column is one package.

Capability Docuccino Scramble Scramble PRO
Validation rules → schema constraints ✓ ✓ ✓
Form requests and inline validate() ✓ ✓ ✓
Spatie Data objects ✓ — ✓
Laravel Actions ✓ — ✓
Spatie Query Builder filters, sorts, includes, fields ✓ — ✓
…recovered when the query is assembled in a helper method ✓ — ✓
…when the allow-lists live in a QueryBuilder subclass’s constructor ✓ — —
Validation constraints folded into bracketed filter[…] parameters ✓ — —

Docuccino maps 80 named Laravel rules onto schema constraints, and documents your own rule classes from a #[RuleSchema] on the class, with a RuleTransformer for anything it doesn’t know.

Query builders are close. Scramble PRO follows the builder out of the route action and through a query object’s query() method, so the common shapes land in both. Docuccino goes on to fold the body of the method that builds an entry: a zero-argument $this->searchFilter() names its filter nowhere but inside itself, $this->facetFilter('status', 'status') names one only once the call site’s arguments are bound to its parameters, and ...$this->allowedFilters() spreads one array-returning helper into every entry it carries, each keeping the comment written beside it. A method with two arms has no single value to fold, so Docuccino declines it and names the entry in a diagnostic rather than picking one.

The constructor row covers the shape where the query class is the builder: it extends QueryBuilder, declares every allow-list in its own constructor, and the action is handed it by the container and calls nothing but the terminal. No call in that action body leads to the configuration, so Docuccino traces the constructor as a root of its own.

The last row is the two halves meeting. Where a Validator::make() rule set validates filter.radius_lat as numeric|between:-90,90, those bounds land on the filter[radius_lat] parameter the allow-list named — one parameter carrying both facts, instead of a bare filter[…] string beside a validated field.

Capability Docuccino Scramble Scramble PRO
API Resources ✓ ✓ ✓
Eloquent model schemas ✓ ✓ ✓
Backed enums, with case descriptions ✓ ✓ ✓
Reusable components/schemas, deduped and $ref’d ✓ ✓ ✓
Pagination envelopes ✓ ✓ ✓
Path parameter types from route-model binding ✓ ✓ ✓
Path parameter names taken from the route definition ✓ — —
Bodyless statuses (204, 205, 304, 1xx) documented without a body ✓ — —
JSON:API resources (timacdonald/json-api) ✓ — ✓
…with the include and fields query parameters ✓ — —
Spatie JSON API Paginate parameters ✓ — ✓
Polymorphic MorphTo → discriminated oneOf ✓ — —

Two of those rows are about matching what the router and HTTP already decided. Where a route declares {token} and the controller receives it as $jwt, the documented parameter is {token} — the name a client puts in the URL. And a 204, 205, 304 or 1xx response carries no body whatever the action’s return type folds to, because HTTP forbids one; response()->json(null, 204) documents as an empty 204, not as a null payload.

Capability Docuccino Scramble Scramble PRO
Security requirements derived from route middleware ✓ ✓ ✓
Sanctum in both token and stateful-cookie modes ✓ — —
Sanctum token abilities (x-abilities) ✓ — —
Passport OAuth2 scopes, per operation ✓ — —
Passport client-credentials (machine-to-machine) ✓ — —
Per-operation scheme override (#[Security], #[OptionallyAuthenticated]) ✓ — —
Role / permission requirements (x-permissions) ✓ — —
Rate-limit 429 responses + Retry-After / X-RateLimit-* headers ✓ — —
A build-time warning for a throttle: name with no RateLimiter::for registration ✓ — —

Both tools read the middleware stack to decide whether an operation is protected. Docuccino goes on to read how: it resolves the guard’s driver, so a custom guard name still lands on the right scheme, and it carries abilities, scopes and permissions through to the operation.

Capability Docuccino Scramble Scramble PRO
Framework defaults (422 / 401 / 403 / 404) ✓ ✓ ✓
Exceptions thrown in the action, and abort() helpers ✓ ✓ ✓
Your real error shapes, read from your exception handling ✓ — —
Error examples, with const-pinned literal members ✓ — —

Reading your actual exception handling is where inference goes furthest, and it’s covered in detail below. Scramble documents the exceptions it can see being thrown — validate(), authorize(), abort(), missing bound models, and @throws one level deep — and leaves the body of a custom error to you. Both approaches get you a documented error response; the difference is whether you hand-maintain its shape beside the code that produces it.

Capability Docuccino Scramble Scramble PRO
Interactive viewer ✓ ✓ ✓
Multiple documents / API versions ✓ ✓ ✓
Build cache ✓ ✓ ✓
Deterministic, byte-stable output ✓ — —
A spec artifact you commit and review in a pull request ✓ — —
Semantic diff over stable node identities ✓ — —
CI gate on breaking changes (docuccino:diff --enforce) ✓ — —
Assertions holding your test suite’s traffic to the spec ✓ — —
A report of documented endpoints your suite never exercises ✓ — —
A documented extension with a published schema (the Docuccino extension) ✓ — —
No static-analysis toolchain in a production install ✓ — —

Everything down to the build cache you can have either way. The rows below it are the ones worth weighing, because they only exist for structural reasons: output is deterministic, every node carries a stable identity, the document is rich enough to assert your test suite’s traffic against, and the analyzer is a dev dependency the adapter names by string rather than a hard requirement. See what Docuccino doesn’t do for the other side of the ledger.

The tables give the shape; this is what the inference actually covers. Whatever you’re migrating from, this is the surface you get without writing an annotation — the useful list to check your own app against:

  • Requests — form requests, inline validate(), and Spatie Data objects, with the full Laravel rule vocabulary (80 named Laravel rules) mapped to schema constraints.
  • Query parameters — Spatie Query Builder filters, sorts, includes, fields and pagination, traced through helper methods several calls deep, folded out of the method that builds each entry, and read from a builder subclass’s constructor when the container hands your action its builder. Validation rules over the same bracketed key merge onto the parameter the allow-list named.
  • Responses — API Resources (including conditional and closure fields), Eloquent model schemas from casts and @property docblocks, Data objects, backed enums with case descriptions, pagination envelopes, JSON:API resources, and polymorphic MorphTo returns as a discriminated oneOf. Beyond JSON: rendered views as text/html, file downloads and streams as a binary body under the media type the call names, and eventStream() as text/event-stream.
  • Reusable components — DTOs, models, resources, request bodies and enums hoisted to components/schemas, deduped and $ref’d.
  • Path parameters — typed from the bound model’s route key (uuid, ulid, integer, string).
  • Authentication — middleware-driven, per guard driver: Sanctum in both token and stateful-cookie modes, token abilities as x-abilities, Passport OAuth2 scopes per operation and client-credentials flows, and role/permission requirements as x-permissions.
  • Errors — the framework’s own 401 / 403 / 404 / 422 shapes, plus your real error bodies read out of your exception handling, down to the media type they are sent as — an application that answers RFC 9457 application/problem+json is documented as answering it, with no option to set.
  • Rate limits — 429 responses with Retry-After and X-RateLimit-* headers, documented by what each header means so every throttled route shares one component, plus a build-time warning for a throttle: name nothing registered.

Error handling deserves a closer look, because it’s where inference goes furthest. Docuccino reads render callbacks, exception render() methods and Responsable exceptions — and where a member of your error body is statically knowable (a per-branch type URI, a title, a status that echoes the response) it pins that member as a schema const and includes it in a generated example, following helper indirection and enum accessors to find it. A member it can’t resolve keeps its declared type in the schema and is never pinned to a guessed value; in the example it is illustrated from that schema — an enum’s first case, a format’s sample, a value the bounds admit — so the example stays a valid instance of the shape beside it, and an optional member your code didn’t supply is left out altogether. Documentation you’d otherwise hand-maintain alongside the code that produces it.

Three things that change about your workflow

Section titled “Three things that change about your workflow”

Annotations you delete

Everything in the list above is inferred, so most parameter and response annotations come out on migration. What’s left are the genuine overrides — the facts your code doesn’t state.

Registration timing stops mattering

Register extensions from any service provider, in register() or boot(), in any order. Nothing resolves until a build starts, so an extension can’t be missed by registering it late.

The spec stops drifting

Commit the output and gate CI on docuccino:diff. Deterministic bytes mean a real change is the only thing that shows up in the diff, and --enforce fails the build when a breaking change ships without the version bump your policy requires.

Most annotations map across cleanly.

Scramble Docuccino Notes
#[QueryParameter] #[QueryParameter] Same idea; Docuccino fills the rest by inference.
#[BodyParameter] #[BodyParameter] Patches one property of the inferred body.
#[PathParameter] #[PathParameter] Adds an OpenAPI format.
#[HeaderParameter] #[HeaderParameter] —
#[CookieParameter] #[CookieParameter] Direct swap.
#[Group] #[Group] Tag assignment; repeatable.
#[Example] / @example #[Example] On an action, pins example payloads — one, or several named ones with their own summaries — on a response, the request body or a parameter, written inline or loaded from a JSON/YAML file. An @example docblock line on any property a schema publishes carries a field-level example.
Exclude a route / controller #[ExcludeFromDocs] Method or class target.
@unauthenticated #[Unauthenticated] Clears the inferred security requirement.
@response {status} #[Response] Patches field-level, keeping inferred siblings.
Hidden fields / $hidden #[Hidden] Property-level or a class-level list, on any class Docuccino hoists; your model’s $hidden / $visible are read anyway. Hides from output.
A field hidden from a request schema #[HiddenFromRequest] Drops a Data-class property from the request body only, leaving the response schema alone.
Enum case descriptions #[CaseDescription] Emitted as x-enumDescriptions.
Markdown description file #[Description(file: …)] Loads a symbol-anchored Markdown file into description. The same attribute takes inline prose as text:.
Deprecation #[DeprecatedOperation] Marks an operation (or controller) deprecated, with an optional reason.

Docuccino also adds attributes with no direct Scramble equivalent — #[Summary], #[OperationId], #[Internal], #[InDocs], #[IgnoreParam], #[IgnoreResponse], #[ResponseHeader], #[SchemaId], #[SchemaName], and the security family #[Security], #[OptionallyAuthenticated] and #[Abilities]. See the full attributes reference.

Docuccino’s config is organized around documents — each an independent output with its own routes, info, security, and viewer — so a few top-level Scramble settings move under documents.default. It reads two files: docuccino.yaml at your project root for everything that shapes a document, and config/docuccino.php for the viewer, which Laravel registers on every boot.

Scramble (config/scramble.php) Docuccino (docuccino.yaml, unless noted)
api_path: 'api' documents.default.routes.include: ['api/*']
api_domain documents.default.routes.filter (or an include glob)
export_path documents.default.export.path
info.version / info.description documents.default.info.version / .description
servers documents.default.servers
ui.title documents.default.info.title
viewer / UI options documents.default.viewer (route, gate, middleware, source, driver) — in config/docuccino.php
enum_cases_description_strategy #[CaseDescription] (emitted automatically as x-enumDescriptions)
extensions extensions, or Docuccino::extend()
security via config/callbacks documents.default.security + the Sanctum/Passport integrations
a single document as many named documents as you need

api_domain is the one row worth a second look. Scramble takes a single host string; routes.filter takes a class implementing RouteFilter, handed each route’s domain along with its URI, name, action and middleware — so “this document is the admin host” is a return $route->domain === 'admin.example.com';, and anything more involved than a literal (a tenant registry, a hosts table) is a constructor dependency, because the container builds the class. It is also the reason the key is a class name and not an inline predicate: the setting lives in docuccino.yaml, and a configuration file has no form for a closure.

See the configuration reference for every option.

The extension model is similar in spirit; the contracts live in Docuccino\Core\Extensions\Contracts.

Scramble Docuccino
Type-to-schema extension TypeToSchema
Operation extension OperationExtension (phased)
Exception-to-response extension ExceptionToResponse
“After document generated” callback DocumentTransformer
Custom validation-rule handling RuleTransformer — or, for one rule class, #[RuleSchema] with no extension at all

The Writing an integration guide walks through building one end-to-end.

Docuccino is new, and there are things it deliberately or simply doesn’t do today. Check this list against how you work before you commit:

  • The generator never runs your code to produce an example. Examples come from inference, from #[Example], or from responses your test suite recorded into committed files (recorded examples) — which the build only ever reads. There is no factory-rendered or live-response example generation inside the generator itself; that’s the trade for one that needs no database and has no side effects, and an endpoint no test exercises gets an inferred or authored example.
  • Closure rules aren’t documented. Built-in Rule:: objects fold statically — Rule::enum (including ->only() / ->except() chains), Rule::in, Rule::exists and Rule::unique — alongside the whole string-rule vocabulary; your own rule classes document from a #[RuleSchema] on the class; and a RuleTransformer teaches Docuccino any rule you invent. But a closure rule, a Rule::when() conditional, a rule class carrying no attribute, or a Rule::in()/Rule::enum() whose values only the runtime knows has opaque logic, so Docuccino raises a diagnostic naming the field rather than guessing at its shape.

You don’t have to switch in one go. Docuccino writes to its own configured path and serves its viewer on its own route, so nothing collides:

Terminal window
php artisan docuccino:export --out=docs/openapi.docuccino.json

Compare it against your existing Scramble output, then migrate in this order:

  1. Move the config — api_path → documents.default.routes.include, info.* and ui.title → documents.default.info, servers, and the UI options → documents.default.viewer. The full map is above.
  2. Delete the annotations Docuccino infers — the parameter and response annotations covered by what Docuccino infers for you. Export and docuccino:diff against the previous export after each pass; the diff tells you whether anything actually left the contract.
  3. Translate the survivors — the attribute map, one controller at a time.
  4. Re-home your extensions — the contract map. Register them from any service provider, in any order.
  5. Commit the document once the output settles, and gate CI on docuccino:diff --enforce.
  6. Decommission Scramble — remove dedoc/scramble (and dedoc/scramble-pro), config/scramble.php, and any published Scramble views.