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.
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:
validate(), and Spatie Data objects, with the full Laravel
rule vocabulary (80 named Laravel rules) mapped to schema constraints.@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.components/schemas, deduped and $ref’d.x-abilities, Passport OAuth2 scopes per operation and client-credentials
flows, and role/permission requirements as x-permissions.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.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.
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:
#[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.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:
php artisan docuccino:export --out=docs/openapi.docuccino.jsonCompare it against your existing Scramble output, then migrate in this order:
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.docuccino:diff against
the previous export after each pass; the diff tells you whether anything actually left the contract.docuccino:diff --enforce.dedoc/scramble (and dedoc/scramble-pro), config/scramble.php,
and any published Scramble views.