Skip to content

Writing an integration

Docuccino is designed to be extended. When it doesn’t know how to document something specific to your app — a custom value object, a bespoke response envelope, a house error format — you teach it with a small class.

An extension is a small class that implements a contract (an interface) and is registered once. You register from any service provider — in register() or boot(), in any package — and Docuccino resolves everything the moment it starts a build. Register early, register late; it works either way, because nothing reads the list until a build asks for it.

use Docuccino\Laravel\Facades\Docuccino;
Docuccino::extend(MoneyToSchema::class);

extend() accepts a class-string, a ready-made instance, or a closure that receives a Registrar — the closure form lets one registration contribute several extensions at once, resolved fresh when the build starts:

use Docuccino\Laravel\Registry\Registrar;
Docuccino::extend(fn (Registrar $r) => $r
->add(MoneyToSchema::class)
->add(HouseErrorResponse::class));

Class-strings are resolved from Laravel’s container, so you can constructor-inject anything you need. Anything listed in the extensions config key is merged in at the same moment, so config and code registration are interchangeable.

Resolution happens once per build, not once per document — a multi-document build shares the same extension instances. Keep your extensions stateless (or key any memoization by route) and everything stays deterministic. A class that implements several contracts is sorted into each one’s chain, so a single registration can teach Docuccino more than one thing.

Say your API returns a Money value object and you want it documented as { amount: integer, currency: string } wherever it appears. You’ll write a type-to-schema mapper — the contract Docuccino uses to turn a PHP type into a JSON Schema.

  1. Implement the contract. supports() claims the types you handle; toSchema() returns the schema (or null to let another mapper try).

    app/Docs/MoneyToSchema.php
    namespace App\Docs;
    use Docuccino\Core\Extensions\Contracts\SchemaContext;
    use Docuccino\Core\Extensions\Contracts\TypeToSchema;
    use Docuccino\Core\Extensions\Schema\SchemaResult;
    use Docuccino\Core\Inference\DType\ClassT;
    use Docuccino\Core\Inference\DType\DType;
    final class MoneyToSchema implements TypeToSchema
    {
    public function supports(DType $type): bool
    {
    return $type instanceof ClassT && $type->fqcn === \App\Support\Money::class;
    }
    public function toSchema(DType $type, SchemaContext $context): ?SchemaResult
    {
    if (! $type instanceof ClassT) {
    return null; // not ours — defer to the next mapper
    }
    return new SchemaResult([
    'type' => 'object',
    'properties' => [
    'amount' => ['type' => 'integer', 'description' => 'Minor units — cents, not dollars'],
    'currency' => ['type' => 'string', 'example' => 'USD'],
    ],
    'required' => ['amount', 'currency'],
    ]);
    }
    }
  2. Register it from a service provider:

    // app/Providers/AppServiceProvider.php → boot()
    use Docuccino\Laravel\Facades\Docuccino;
    Docuccino::extend(\App\Docs\MoneyToSchema::class);
  3. Export. Anywhere a Money appears — a resource field, a DTO property, a response — it’s now documented with your schema.

    Terminal window
    php artisan docuccino:export

toSchema() receives a SchemaContext with everything you need to build a correct, cacheable schema:

Method Use it to…
convert(DType $type): array Convert a nested type through the full mapper chain (e.g. a property whose type is itself complex).
convertMember(DType $member): array Convert one member of a composite you’re building at the CURRENT position — a union branch, an intersection member. Root-ness carries through, so a root envelope still lands on every arm; convert() would descend a position and hide it.
reference(string $name, array $schema, ?string $schemaId = null): array Hoist a named schema into components.schemas and get back a { "$ref": … } — the correct way to make a reusable, shareable schema.
reserveComponentName(string $name, string $schemaId): string Reserve the final component name (with collision suffixing) before building the body, so a self-referential schema can point its cycle-breaking $ref at the exact name. Never fabricate a component name or #/components/schemas/… ref yourself.
dependsOn(string ...$files): void Declare the source files your schema was built from, so cached output is invalidated when they change. Pass the file of any class you reflect (an enum or model file too). Empty strings are ignored.
lowerConfidence(float $confidence): void Note that this conversion was a best guess. The lowest value seen wins, and it’s recorded in provenance.
diagnostic(Diagnostic $d): void Surface a problem (shown in CLI output, and able to fail CI).
engine(): TypeEngine The inference engine, for classMetadata() class expansion.
atRoot(): bool Whether what you’re building occupies the response or parameter root. Read it when your output depends on being at the root — a top-level wrapping key, say. A union branch is at the root when its union is, so never spell this as depth() === 1.
depth(): int Recursion depth of the running conversion — 1 at a response or parameter root, deeper for each conversion nested inside it, composite members included. For root-ness use atRoot().
representation(): RepresentationPolicy The document’s representation policy (enum naming, how nullability is expressed, and so on).

convert() never returns null — an unresolvable type comes back as {}, so you can always nest it safely.

For a reusable component, prefer reference() over inlining, and call dependsOn() so caching stays correct:

$context->dependsOn((new \ReflectionClass($type->fqcn))->getFileName() ?: '');
return new SchemaResult(
$context->reference('Money', [/* the schema above */], $type->fqcn),
);

Some output belongs only at the top of a body — a wrapping key, an envelope, a meta sibling. Ask atRoot() for that, and build any composite you emit with convertMember(), which converts a member that stands in the same place as the composite itself. A union branch is at the root when its union is, so the envelope lands on every arm instead of vanishing under the first anyOf:

public function toSchema(DType $type, SchemaContext $context): ?SchemaResult
{
$schema = $context->reference('Receipt', [/* … */], $type->fqcn);
// Only the top-level receipt is enveloped; nested ones stay bare.
return new SchemaResult($context->atRoot() ? [
'type' => 'object',
'properties' => ['data' => $schema],
'required' => ['data'],
] : $schema);
}
// A composite of your own: each member is converted where the composite stands, so a member
// that wraps at the root still gets to.
return new SchemaResult(['anyOf' => array_map(
static fn (DType $member): array => $context->convertMember($member),
$type->members,
)]);

Reach for convert() whenever the type really is somewhere else — an array’s items, an object’s property — and depth() only when you want the literal recursion count.

Mappers form a chain and the first match wins, so order matters when your mapper covers a type a built-in also handles — a model, an enum, a Data class. Annotate with #[ExtensionOrder]:

use Docuccino\Core\Extensions\Ordering\ExtensionOrder;
use Docuccino\Core\Extensions\Ordering\Priorities;
#[ExtensionOrder(priority: Priorities::FIRST)]
final class MoneyToSchema implements TypeToSchema { /* … */ }
Priority Value When to use
Priorities::FIRST 1000 Run before everything, built-ins included.
Priorities::EARLY 100 Ahead of the plain mappers. Several built-ins also sit here (model, enum and Data-class mappers), so this does not guarantee you beat a specific one.
Priorities::DEFAULT 0 The normal case — no opinion about order.
Priorities::LATE -100 Deliberately after the built-ins.
Priorities::LAST -1000 Absolute last resort (where the terminal “unknown type” mapper lives).

So when your type must be claimed by your mapper and nothing else, use Priorities::FIRST rather than EARLY — that’s above every built-in, with no tie to break.

For ordering between your own extensions, name them and let the sort figure it out — here a broad fallback mapper that should only get a look once the specific one has declined:

#[ExtensionOrder(after: [MoneyToSchema::class])]
final class ValueObjectToSchema implements TypeToSchema { /* … */ }

before: and after: become real edges in a topological sort, so the outcome is guaranteed rather than a matter of tie-breaks — and a cycle between them fails the build loudly instead of silently picking an order. Ties break by priority descending, then class name, then registration index, so the emitted bytes never depend on which provider registered first.

Because your supports() only claims your own types, running early is safe: everything you don’t claim falls straight through to the built-ins.

Pick the contract that matches what you’re teaching Docuccino. They all live in Docuccino\Core\Extensions\Contracts, and a single class may implement several — the registry partitions each instance into every chain it qualifies for.

These are the five you’ll reach for most:

Contract Implement it to… Methods
TypeToSchema Turn a PHP type into a schema. supports(DType $type): bool, toSchema(DType $type, SchemaContext $context): ?SchemaResult
OperationExtension Add or adjust parts of an operation (parameters, responses, security…). phase(): OperationPhase, handle(OperationDraft $operation, RouteContext $context): void
ExceptionToResponse Document a response for a thrown exception. supports(ThrownException $exception, RouteContext $context): bool, toResponse(ThrownException $exception, RouteContext $context, ComponentRegistry $components): ?ResponseDraft, producer(): string
RuleTransformer Map a validation rule to schema keywords. supports(ValidationRule $rule): bool, apply(ValidationRule $rule, ValidationField $field, SchemaContext $context): void, handledRuleNames(): array
DocumentTransformer Post-process the whole finished document. transform(UirDocumentDraft $document, DocumentContext $context): void

And these cover the rest of the surface:

Contract Implement it to… Methods
RouteResolver Contribute routes beyond Laravel’s router. resolve(DocumentConfig $document): iterable
RouteFilter Decide whether one discovered route belongs in a document, after the routes.include/routes.exclude globs. Configured by name under routes.filter rather than registered. includes(RouteDescriptor $route): bool
ValidationRulesToSchema Replace the whole rule-set → request-schema step. Almost always the wrong lever: add a RuleTransformer instead. convert(RuleSet $rules, SchemaContext $context): ValidationSchema
ResponseAnalysisTarget Point success-body inference at a different method than the dispatched action (the way Laravel Actions’ jsonResponse() is read). resolve(RouteContext $context): ?ResponseAnalysisRedirect
ResponseStatusResolver Override the success status a returned class documents (a 201 from a Data class’s calculateResponseStatus(), say). resolveStatuses(RouteContext $context, string $fqcn): array
PayloadMediaTypeResolver Classify a payload’s media type — application/vnd.api+json for a JSON:API resource, for instance. mediaTypeFor(DType $payload): ?string
RouteBindingSchemaResolver Type a route-model-bound path parameter from the bound model’s route key. keySchemaFor(string $modelFqcn): ?array
RouteBindingFieldSchemaResolver The same, for a binding that names its own column ({post:slug}). Extends the contract above, so one class answers both; returning null documents a plain string rather than falling back to the route key. fieldSchemaFor(RouteContext $context, string $modelFqcn, string $field): ?array
RouteBindingKeyResolver Name the column a route-model-bound path parameter is matched on, so the parameter can say so to a consumer who cannot see your models. Return null for anything your resolver cannot settle from a declaration — a short description costs nothing, a wrong one sends clients to look records up by the wrong attribute. keyNameFor(string $modelFqcn): ?string
EnvironmentDigestContributor Fold booted-app state your extension reads (guards, global config) into the cache key. digest(): string
RouteNoteCollector Aggregate a finding many routes make into one your DocumentTransformer reports. channel(): string, forget(): void, collect(string $key, array $values): void
TagMapper Customize how raw tags map to display tags. map(string $tag): string
Viewer Render the docs page yourself, selected by name through viewer.driver. See the viewer guide. name(): string, render(ViewerContext $context): mixed
ViewerAssets Ship a script with your viewer and serve it from the gated asset route instead of a CDN. assets(): array
ViewerSpecVersion Declare the OpenAPI minor your viewer’s pinned build actually implements ('3.0', '3.1' or '3.2'), so the spec endpoint downlevels to it instead of serving a version the build reads by aliasing. specVersion(): string

The resolver contracts are chains resolved per document, and each returns a “not mine” value — null, or [] for resolveStatuses() — to defer to the next implementation. The first one that answers wins, and a chain nobody answers falls back to Docuccino’s default behavior, so registering yours is additive: anything you don’t claim is untouched.

An EnvironmentDigestContributor is worth a second look if your extension reads anything outside its own code and the route’s own files — a config value, a registered guard, a global default. Your extension’s own source is keyed for you: the cache hashes the files your class, its parents and its traits are written in, so editing it rebuilds without your doing anything. A fact you read from booted state is a different matter — nothing hashes it, and a warm cache would keep serving the old answer. Return a short deterministic string built from that state (the empty string when nothing resolves) and the cache invalidates when it changes.

A RouteNoteCollector answers a different cache question: what to do when a finding belongs to the whole document but is discovered one route at a time. Reporting it per route gives a 200-route app 200 near-identical lines, and holding a running total in your extension makes it vanish from a warm build — a cached route runs no extension, so nothing adds to the total. Record the fact on the route instead:

$context->notes()->record('my-integration.unfoldable', $subject, $detail);

Notes ride the route’s cached fragment, and Docuccino replays them into the collector whose channel() matches on every build, warm or cold. Your DocumentTransformer then reads the collector and reports once. forget() is called before each document’s first route, so an export of several documents never reports one document’s findings against another’s.

OperationExtensions run in phases, so you can act at the right moment:

Parameters → Request → Responses → Errors → Security → Overrides →

Finalize

Every write you make to an OperationDraft is merged by precedence and recorded in provenance, so you never have to worry about clobbering another extension’s contribution — higher layers win field by field, losers are recorded, not lost.

An OperationExtension mutates an OperationDraft in a phase. Here’s the whole shape — add a response header on every operation your extension applies to:

use Docuccino\Core\Draft\OperationDraft;
use Docuccino\Core\Extensions\Context\RouteContext;
use Docuccino\Core\Extensions\Contracts\OperationExtension;
use Docuccino\Core\Extensions\Contracts\OperationPhase;
use Docuccino\Core\Patch\Contribution;
final class RequestIdHeader implements OperationExtension
{
public function phase(): OperationPhase
{
return OperationPhase::Responses;
}
public function handle(OperationDraft $operation, RouteContext $context): void
{
$by = Contribution::integration('request-id', $context->actionSource());
$response = $operation->response('200');
$response->set('headers', [
'X-Request-Id' => ['schema' => ['type' => 'string', 'format' => 'uuid']],
], $by);
}
}

$by is a Contribution — it stamps each write with a precedence layer and a source location, so the merge is deterministic and the provenance explains itself. Build one with a factory that matches where your fact came from:

Factory Layer Rank Use it for
Contribution::fallback($source) fallback 5 A last-resort default, below inference.
Contribution::inference($source, $confidence) inference 10 A fact you analyzed from code.
Contribution::integration($name, $source, $confidence, $specificity) integration 20 A package-specific contribution. Names the producer integration:<name>, which is what shows up in provenance. $specificity is how your extension beats a built-in that already owns the field.
Contribution::docblock($source) docblock 30 A value read from a docblock.
Contribution::attribute($source, $specificity) attribute 40 A value the developer declared with an attribute.

The rank is what decides a contest for a field: a strictly higher rank overwrites and pushes the old value onto that field’s overrode trail; an equal or lower one is recorded as shadowed and changes nothing. Within one layer, $specificity breaks the tie (higher wins) — that’s how an attribute on an action beats the same attribute on its controller.

Two ranks sit above these in the model — overlay (45) and config (50) — and neither belongs in an extension. An OpenAPI Overlay is applied to the assembled document after every draft has frozen, so it has the last word on the nodes it targets no matter what the pipeline wrote there. Treat both as the developer’s final say and leave them alone.

Pass $context->actionSource() (or $context->sourceAt($location)) as the source so provenance points at real, project-relative code, and pass a $confidence below 1.0 when your fact is a best guess.

Every setter takes the value and a Contribution $by; the nested parameter() / response() drafts own their own guarded writes:

Method Writes
setSummary(?string, $by) / setDescription(?string, $by) The operation summary / description.
setTags(?array, $by) / setSecurity(?array, $by) Tags, or the security requirement list.
setDeprecated(?bool, $by) / setOperationId(?string, $by) Deprecation flag, operationId.
set(string $field, mixed $value, $by) Any other top-level operation field.
parameter(string $in, string $name): ParameterDraft Get (or create) a parameter draft, keyed by (in, name).
response(string $status): ResponseDraft Get (or create) a response draft, keyed by status.
hasParameter / removeParameter, hasResponse / removeResponse Test or drop a child draft.
resolvedField(string $field): mixed Read the field’s current winning value — how you branch on what inference already set.
producerFor(string $field): ?string Which producer currently owns that field (inference, attribute, integration:sanctum…).

resolvedField() and producerFor() are on every draft, so the same two reads work on a parameter, response or schema draft too.

A ParameterDraft offers setDescription(?string, $by), setRequired(?bool, $by), setDeprecated(?bool, $by), set(field, value, $by), and schema() (a SchemaDraft whose own set(keyword, value, $by) builds the parameter’s JSON Schema); setDocuccinoFact(key, value) records a representation-independent semantic fact.

A ResponseDraft — which you also construct directly, new ResponseDraft($status), when an ExceptionToResponse returns one — offers setDescription(?string, $by), setRef(?string, $by), set(field, value, $by), content(string $mediaType): SchemaDraft for the response body schema, hasContent() / primaryMediaType() to inspect what’s there, and setExample(string $mediaType, mixed $example, array $placeholders = []) to attach an example body alongside that media type’s schema. Examples are first-writer-wins: if another producer already attached one for that media type, yours is left out, so the result never depends on extension evaluation order. An example is only emitted when that media type also carries a schema, so set the body schema too. This is exactly the method the built-in inferred-handler support uses to attach its error-response examples — no separate back door. $placeholders names the members you filled from a declared type rather than read from the code: an error body’s shared-response hoist drops an illustration that differs from another only at members it filled, and that set is how it tells one from two genuinely different bodies. Pass none — the default — and every member of your example counts as read, which is what makes it an illustration the hoist may never drop. claimComponentName(?string $name, $by) names the shared component an error body publishes under — see below.

A SchemaDraft adds property(string $name): self for nesting into an object’s properties, hasProperty(string $name): bool, and declareShape(array $schema, $by) for writing a whole schema in one go.

Reach for declareShape() whenever you have a converted type in hand and were about to write its keywords in a loop. A schema’s keywords compose as a conjunction, so writing them one at a time leaves the ones your shape replaced standing beside it — an inferred additionalProperties next to your closed properties publishes a body open to keys you never named, and an inferred type/items next to your $ref says the body must satisfy both. declareShape() states the shape whole: the keywords it leaves out are retracted wherever your contribution outranks them, the refinements that still hold for the type you declared (a format, an enum) stay, and annotations such as description are never touched.

$body = $operation->response('200')->content('application/json');
$body->declareShape($context->converter()->toSchema($type)->schema, $by);

Naming the component an error publishes under

Section titled “Naming the component an error publishes under”

An error body two or more operations state identically is hoisted into components.schemas and components.responses, and the name it lands under is the name a code generator gives the type. Left unnamed that is Error404 — or Error404_2obip4vj where a status carries two bodies.

Two people pay for that name. Whoever consumes the API cannot see the code behind it, and a catch on Error404_2obip4vj tells them nothing about what went wrong. And you, who can see the code and know exactly which error this is, have no way to say so. Claiming a name fixes both:

$by = Contribution::integration('invoices');
$response = $operation->response('404');
$response->claimComponentName('NotFound', $by);
$response->setDescription('Not Found', $by);
$response->content('application/json')->set('type', 'object', $by);

The same call works on a ResponseDraft you build in an ExceptionToResponse, which is where a mapper names the error it speaks for:

public function toResponse(ThrownException $e, RouteContext $context, ComponentRegistry $components): ?ResponseDraft
{
$by = Contribution::integration('invoices');
$draft = new ResponseDraft('404');
$draft->claimComponentName('InvoiceMissing', $by);
$draft->setDescription('Not Found', $by);
$draft->content('application/json')->set('type', 'object', $by);
return $draft;
}

Claim one when your producer speaks for exactly one kind of error. A tier that documents “the invoice was not found” — however many exception types render it — knows what to call it. That is the whole test.

Don’t name it after the exception class. The relationship isn’t one-to-one in either direction: three exception types routinely render one body, and one exception can render two. Pick one of the three class names and deleting an unrelated route can change which name survives; let one exception claim one name for two bodies and it contests itself. The name has to come from the thing you are documenting, not from whichever class happened to throw. (An application marking its own exception with #[ErrorComponent] is saying the opposite thing — that this class is that one error — and its claim still rides the response, so it settles by the same rules. Yours, speaking for a body you built, outranks it.)

Don’t claim a name you can’t speak for. A generic fallback that hands every unrecognized exception a {message} body knows the status and nothing else, so a status with no meaning of its own is better left as Error<status> than named something a client would trust.

Two different bodies claiming one name is a contest, and a suffix is the right answer. If your NotFound and someone else’s NotFound are not the same bytes, neither keeps the plain name: both are published as NotFound_kzvq2m4a and the build warns with components.name-collision naming each claimant. That degradation is deliberate — handing the name to whichever the build met first would let an unrelated route silently change what a generated type means. Seeing a suffix means two things really are contesting the name; give them a name each and the suffix goes away.

The rest of the rules:

Legal names ^[a-zA-Z0-9._-]+$. Anything else is refused at the call — read as no declaration at all, so it never displaces one already standing. The response keeps whatever else named it, or the status default where nothing did, and the document is never emitted invalid.
Two producers, one response Guarded like every other write, so the higher-precedence contribution’s name wins and the other is shadowed.
One name, two bodies you meant to differ Claim a distinct name from each producer. Two names sharing a body publish two components on purpose — that is what makes each name a function of its own declarer.
A name already taken A component published before the hoist runs keeps it, and your body climbs past to a discriminated name.
Only bodies that repeat A body one operation states stays inline and is never named at all, claim or no claim. What counts is how often the status and body occur, so a body another route states without naming still shares — you get a component under your name and it keeps Error<status>.

Docuccino’s own tiers name Laravel’s errors BadRequest, Unauthorized, Forbidden, NotFound, MethodNotAllowed, NotAcceptable, Conflict, Gone, LengthRequired, PreconditionFailed, ContentTooLarge, UnsupportedMediaType, UnprocessableEntity, Locked, PreconditionRequired, TooManyRequests, InternalServerError and ServiceUnavailable — the full table, with what produces each, is on Error responses. Four supported ways to change one, in the order to reach for them.

  1. Name the response on the operation that declares it. Where the action declares the status itself with #[Response], its errorComponent: argument names the response — no extension, and the highest of the four, so it stands over anything the paths below claim:

    use Docuccino\Attributes\Response;
    #[Response(status: 422, type: SignInChallenge::class, errorComponent: 'AuthenticationChallenge')]
    public function completeMfa(Request $request): SuccessData { /* … */ }

    It is also the only anchor that reaches a body nothing threw, which is why it is not simply a spelling of the attribute below: #[ErrorComponent] is read off the exception classes a route signals and the render methods on their path, and a status an operation declares for itself is on neither. Full behavior: Name one operation’s error.

  2. Mark the exception. For your own exception classes this is the whole job — no extension, no registration:

    use Docuccino\Attributes\ErrorComponent;
    #[ErrorComponent('ResourceMissing')]
    final class InvoiceNotFoundException extends RuntimeException {}

    It reaches the response through the same claimComponentName() you would call yourself, contributed at the attribute layer, so it outranks the status name a built-in tier claimed and loses to a mapper that named the body. It is inherited, nearest declaring class first, so a base your errors extend can name them all at once. Reach past it when the class can’t say enough — one exception rendering several different bodies needs the mapper below, which sees each body as it builds it — or when the exception isn’t yours to mark. Full behavior: #[ErrorComponent].

  3. Replace the mapper. Register your own ExceptionToResponse for the exception. The chain takes the first mapper that both supports the throw and answers, and an unannotated extension sorts at Priorities::DEFAULT — ahead of the framework-errors tier (LATE) and the terminal fallback (LAST), so yours wins over both, body and name together, with no #[ExtensionOrder] at all. One tier still runs ahead of it: the inferred-handler tier (FIRST), which documents the shape your app’s own handler really returns. To beat that, say so — #[ExtensionOrder(priority: Priorities::FIRST + 1)].

    final class InvoiceNotFound implements ExceptionToResponse
    {
    public function supports(ThrownException $e, RouteContext $context): bool
    {
    return is_a($e->exceptionFqcn, ModelNotFoundException::class, true);
    }
    public function producer(): string
    {
    return 'integration:invoices';
    }
    public function toResponse(ThrownException $e, RouteContext $context, ComponentRegistry $c): ?ResponseDraft
    {
    $by = Contribution::integration('invoices');
    $draft = new ResponseDraft('404');
    $draft->claimComponentName('ResourceMissing', $by);
    $draft->setDescription('Not Found', $by);
    $draft->content('application/json')->set('type', 'object', $by);
    return $draft;
    }
    }
  4. Keep the body, rename the component. An OperationExtension in the Finalize phase claims a name over whatever the built-in claimed. The claim is guarded like every other write, so precedence decides — and the tiers do not all claim at one layer. The generic fallback claims at fallback; the framework-error and rate-limit tiers claim at integration, the same layer your extension writes at, and an equal contribution is shadowed. So break the tie the way every other field breaks one, with specificity, which clears both:

    public function handle(OperationDraft $operation, RouteContext $context): void
    {
    if (! $operation->hasResponse('404')) {
    return;
    }
    $operation->response('404')->claimComponentName(
    'ResourceMissing',
    Contribution::integration('invoices', specificity: 1),
    );
    }

    Only the name changes; the built-in’s body, description and provenance stay exactly as they were. An #[ErrorComponent] on the exception is a rung above, at the attribute layer, so this path renames what a built-in claimed and not what an application declared for itself.

RouteContext is your window onto the route. Its readonly properties carry the facts — route (a RouteDescriptor: URI, methods, name, middleware), actionRef (the controller class, method, file and line), attributes (an AttributeSet, most-specific first), document (the DocumentConfig), pathParameters, routeBindings, summary / description from the docblock, and components (the document’s ComponentRegistry, also where diagnostics land).

The methods you’ll reach for:

Method Gives you
analysis(): ActionAnalysis The action’s inference result — return types, thrown exceptions, recovered request. Computed once and memoized across phases.
trace(TraceVisitor $v): TraceReport Drive an interprocedural trace from the action. Its dependency files are recorded for you — use this rather than the engine directly, or your cache key comes out short.
traceFrom(ActionRef $root, TraceVisitor $v): TraceReport The same walk from a root the action body never reaches — the constructor of an injected query object, a closure handed to a facade. Seed those roots through this and never through $context->engine->trace(): it records the walk’s dependency files too, so editing the traced file re-documents the route instead of leaving a stale fragment warm.
recordDependencyFiles(array $files): void Register files you read out-of-band (a config file, a class you reflected yourself), so editing them invalidates the cached fragment. dependencies() hands back the underlying bag with addFile() / addFiles() if you prefer.
converter(): TypeSchemaConverter The type→schema converter over the document-wide component registry: $context->converter()->toSchema($type)->schema. It’s also the SchemaContext mappers are handed, so it goes straight into validation()->convert().
validation(): ValidationRulesToSchema The rule→schema converter over the resolved transformer chain — feed it a RuleSet you recovered.
httpMethod(): string The specific verb this context documents, lower-cased. A multi-method route gets one context per method, so branch request-body vs query on this and not on the route.
representation(): RepresentationPolicy The document’s representation policy, for output that has a configured shape.
actionSource(): ?Source / sourceAt(SourceLocation): ?Source Project-relative provenance sources for your contributions.

Report anything you can’t resolve with a Diagnostic — it shows in CLI output and can fail CI under --fail-on:

use Docuccino\Core\Diagnostics\Diagnostic;
use Docuccino\Core\Diagnostics\Severity;
$context->components->addDiagnostic(new Diagnostic(
severity: Severity::Info,
code: 'request-id.header-unresolved',
message: 'Could not determine the request-id header format; documented as a plain string.',
help: 'Type the header value so its format can be recovered.',
));

A Diagnostic takes a Severity — Error, Warning, Info or Hint — a stable dotted code, a message, and optionally a source, a routeSignature and a help line — help prints indented under the message, so put what to change there and what went wrong in the message. Pick the severity by what you want CI to do with it: --fail-on takes any of the four as a floor, so each is reachable — but the floor a team picks is what decides whether yours breaks their build. Error is a malfunction, Warning something they should change, Info the channel for “I recovered less than I wanted”, and Hint a note about the analysis itself.

From a TypeToSchema, $context->diagnostic($d) does the same thing.

To share your work as a Composer package, follow the same shape every built-in integration uses: one small entry-point class that lists your extensions and checks whether its target package is installed, plus a service provider that registers them.

  • composer.json
  • Directorysrc/
    • StripeIntegration.php the entry point — what’s installed, and what it contributes
    • StripeIntegrationServiceProvider.php
    • StripeToSchema.php
    • StripeWebhookExtension.php

The entry point holds no logic beyond those two questions:

src/StripeIntegration.php
namespace Acme\DocuccinoStripe;
final class StripeIntegration
{
/** Only activate when the package we document is actually present. */
public static function installed(): bool
{
return class_exists(\Stripe\StripeClient::class);
}
/** @return list<class-string> */
public static function extensions(): array
{
return [StripeToSchema::class, StripeWebhookExtension::class];
}
}

The provider registers them, guarded so the package is inert when the target isn’t installed. Nothing here needs Docuccino to have booted — the registry resolves at build time — so register() is enough:

src/StripeIntegrationServiceProvider.php
namespace Acme\DocuccinoStripe;
use Docuccino\Laravel\Facades\Docuccino;
use Illuminate\Support\ServiceProvider;
final class StripeIntegrationServiceProvider extends ServiceProvider
{
public function register(): void
{
if (! StripeIntegration::installed()) {
return;
}
foreach (StripeIntegration::extensions() as $extension) {
Docuccino::extend($extension);
}
}
}

Auto-discover it from your composer.json and installing the package is all a user has to do:

{
"extra": {
"laravel": {
"providers": ["Acme\\DocuccinoStripe\\StripeIntegrationServiceProvider"]
}
}
}