Skip to content

feat(#108): resolve concrete Resource/model type from the controller return expression#253

Merged
Radiergummi merged 4 commits into
mainfrom
feat/108-return-expression-resource-type
Jun 12, 2026
Merged

feat(#108): resolve concrete Resource/model type from the controller return expression#253
Radiergummi merged 4 commits into
mainfrom
feat/108-return-expression-resource-type

Conversation

@Radiergummi

Copy link
Copy Markdown
Owner

Stack

#219#225#230#235#241#247#251this (base: fix/249-250-model-metadata-gaps).

Closes #108.

What

When an action's signature gives only a base Resource type (JsonResource, bare ResourceCollection, AnonymousResourceCollection) — i.e. ResourceClassLocator finds nothing concrete today — resolve the concrete resource (or wrapped model) from the method's return expression: a bounded Tier-1 scan per epic #5, no dataflow. Per #106 this unlocks ~77 corpus operations (Vito 42, Bagisto 34, Koel 1) plus the 3 ->toResource()/new JsonResource($m) ops, feeding the #12 toArray() reader through the existing SchemaFromResource::buildRef() seam.

Plan

  1. ResourceTarget gains ?string $modelClass = null (wrapped-model targets) and bool $paginated = true (collection envelope choice); isAmbiguous() requires both class fields null. Lint rules that read $target->resourceClass after the ambiguity check get a null-guard for model-only targets.
  2. New ReturnExpressionResourceReader (Plugins\ApiResources\Support, @internal, #[Scoped], memoised per method so notes fire once per run):
    • Consulted only on a Tier-0 miss (perf invariant; epic EPIC: Controller method-body inference (Tier-1 AST whitelists) #5 recipe). PHPDoc @return …<FooResource> generic is read first (no AST parse — DocBlockParser + TypeNodeResolver::genericValueClass already do this); the body scan only runs when the docblock yields nothing. PHPDoc wins over the body when both resolve.
    • Body scan: MethodBodyScanner first-10-statements + StatementNodeFinder with SkipConditionalContexts. The first top-level return is the canonical one; a return whose expression is a plain variable resolves through the single unconditional assignment to that variable inside the scanned region (the two-statement case); multiple or conditional assignments refuse.
    • Whitelisted shapes: X::collection(arg) (collection of X), X::make(…) / new X(…) (single X) where X resolves via NameResolver to a concrete JsonResource subclass that is not a ResourceCollection; ->toResource(X::class) / ->toResourceCollection(X::class) (explicit class argument is decisive); bare $param->toResource() where $param is a Model-typed method parameter — the resource class resolves through Laravel's own convention (#[UseResource] attribute, then guessResourceName() candidates); new JsonResource($param) (exactly the base class) with a Model-typed parameter → wrapped-model target (model component via EloquentModelToSchema, single {data} envelope).
    • Chained calls: ->additional(…) on a matched shape is ignored (resource class stays certain; the extra envelope keys are not modelled). Any other chain link refuses with a note.
    • Pagination: a collection whose argument chain (or toResourceCollection receiver chain) visibly ends in paginate/simplePaginate/cursorPaginate keeps the {data, links, meta} envelope; otherwise the collection documents as a plain {data: […]} envelope — pagination meta is only claimed when statically knowable. Signature/attribute-resolved collections keep today's paginated envelope (no body information; existing behaviour).
    • Anything else: refuse → today's behaviour + one generation-log NOTICE per action naming #[ResponseResource] as the escape hatch.
  3. ResourceClassLocator widening (the only consumer-visible seam change): after the #[ResponseResource] check and the signature read, an ambiguous collection or an exactly-base single JsonResource return consults the reader; a null read keeps today's result (ambiguous target / empty base schema). Attribute precedence is pinned end-to-end by test.
  4. ResourceResponseResolver: model-target branch + unpaginated-collection envelope branch; ResourceEnvelopeFactory::unpaginatedCollection().
  5. Tests: feature coverage under tests/Feature/Plugins/ApiResources/ReturnExpressionResolutionTest.php (shapes: ::collection paginated + unpaginated, ::make, new X, ->toResource() typed-param + explicit class, new JsonResource($m), @return generic incl. PHPDoc-vs-body precedence, two-statement, refused variable, refused conditional, chained additional, attribute-wins); unit tests for the reader under tests/Unit/Plugins/ApiResources/. Lint interplay: resource.response-ambiguous no longer fires for body-resolved collections.
  6. Examples: examples/api-resources gains one idiomatic BookingResource::collection() index route with no #[ResponseResource]; snapshot regenerated and diff verified to be exactly the new operation.
  7. Docs: docs/plugins.md ApiResources section + docs/auto-derivation.md response-body row; CHANGELOG [Unreleased].

Scope decisions (recorded as they land)

🤖 Generated with Claude Code

@Radiergummi Radiergummi added area:plugins Bundled convention plugins area:responses Response-body inference enhancement New feature or request tier-1 Tier 1 — bounded AST whitelist labels Jun 10, 2026
@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.96552% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.31%. Comparing base (49420e9) to head (817fbed).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...sources/Support/ReturnExpressionResourceReader.php 88.21% 29 Missing ⚠️
...iResources/Lint/Rules/ResourceFieldTypeMissing.php 50.00% 1 Missing ⚠️
...iResources/Lint/Rules/ResourceFieldsUndeclared.php 50.00% 1 Missing ⚠️
...gins/ApiResources/Support/ResourceClassLocator.php 92.85% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #253      +/-   ##
============================================
- Coverage     92.38%   92.31%   -0.08%     
- Complexity     5489     5607     +118     
============================================
  Files           397      398       +1     
  Lines         14701    14983     +282     
============================================
+ Hits          13582    13832     +250     
- Misses         1119     1151      +32     
Files with missing lines Coverage Δ
...Resources/Lint/Rules/ResourceResponseAmbiguous.php 100.00% <100.00%> (ø)
...piResources/Resolvers/ResourceResponseResolver.php 100.00% <100.00%> (ø)
...s/ApiResources/Support/ResourceEnvelopeFactory.php 100.00% <100.00%> (ø)
src/Routing/ResourceTarget.php 100.00% <100.00%> (ø)
...iResources/Lint/Rules/ResourceFieldTypeMissing.php 96.66% <50.00%> (-3.34%) ⬇️
...iResources/Lint/Rules/ResourceFieldsUndeclared.php 94.87% <50.00%> (-2.43%) ⬇️
...gins/ApiResources/Support/ResourceClassLocator.php 92.75% <92.85%> (-0.11%) ⬇️
...sources/Support/ReturnExpressionResourceReader.php 88.21% <88.21%> (ø)

@Radiergummi

Copy link
Copy Markdown
Owner Author

Implementation decisions (record)

  1. ->toResource() scope — clean subset, plus a deliberate deviation from the issue text. The explicit-class forms ->toResource(X::class) / ->toResourceCollection(X::class) match on the literal class argument alone — the receiver's type is irrelevant to the schema, so requiring a typed receiver there would only refuse ops for no fidelity gain (this is AdvisingApp's actual shape). The bare $model->toResource() form requires a Model-typed method parameter as receiver; inline Model::find/… receiver chains degrade with a note (OAPI-017 opportunity sizing across 11 OSS apps — prioritises #12 + an under-tracked lever-2 (body-level Resource type resolution) #106 sized the whole sub-shape at 3 ops — not worth the chain-walking). Deviation: the issue says bare toResource() should hand off to the model schema (EPIC: Response-body schema fidelity (Tier-0 sources) #6/Eloquent model → schema from $casts / $hidden / $appends #18); the runtime actually serialises the conventional resource (#[UseResource], then guessResourceName()), so the reader resolves that resource instead — more faithful — and refuses when no conventional resource exists (the runtime would throw LogicException there anyway). new JsonResource($model) (exactly the base class) is the one shape that documents the model schema directly, via a new ResourceTarget::$modelClass channel.
  2. PHPDoc @return …Collection<X> wins over the body. It is explicit authoring (closest to an attribute) and costs no AST parse — the body scan only runs when the docblock yields nothing. Disagreement therefore silently resolves to the docblock; pinned by test.
  3. Chained calls: whitelist ->additional(...), refuse everything else with a note. additional() changes neither the resource class nor the cardinality; its extra envelope keys are not modelled (under-documenting siblings beats refusing the whole op). Other chain links (->preserveQuery(), collection proxies, …) may transform the response, so they refuse — cheap to extend the whitelist if the corpus shows more identity-preserving links. ->response() chains cannot appear under the entry condition (the signature would then be JsonResponse, not a base resource type).
  4. Pagination envelope is evidence-based for body-resolved collections. Only a source whose outermost call is paginate() / simplePaginate() / cursorPaginate() (method or static Model::paginate()) claims the {data, links, meta} envelope; everything else (->get(), Author::all(), an opaque receiver) documents the new plain {data} envelope — pagination meta is never guessed. All three paginate variants document as the dominant length-aware meta shape (its envelope properties are optional, so simple/cursor responses stay valid against it, if over-described). Signature- and attribute-resolved collections keep today's always-paginated envelope (ResourceTarget::$paginated defaults true) — no body information exists for them, and changing that would churn every existing consumer for no evidence.
  5. Conditional policy. The canonical return is the first top-level Return_ in the first 10 statements; if any other method-level return exists in the scanned region (inside if/switch — closures/arrow-functions/anonymous classes excluded as their own scopes), the type is a guess → refuse + note. The two-statement form requires the returned variable to be assigned exactly once anywhere in the scanned region and that assignment to sit on the unconditional path (StatementNodeFinder both policies must agree), so conditional reassignment refuses.
  6. Entry condition / Tier-0 gating (perf). The reader is consulted only when the signature names a base type: an ambiguous collection (bare ResourceCollection / AnonymousResourceCollection / a user collection without #[Collects]/$collects) or exactly JsonResource. Concrete-typed signatures never parse source. Untyped returns stay out of scope (per the issue's UNRESOLVED bucket; they also belong to the inline-response()->json() resolver's territory). Per-method memoisation on top of the scanner's per-file AST cache; refusal notes fire once per action and run. Side effect worth naming: the AdvisingApp "latent sub-bug" (a base-JsonResource single silently shipping an empty {data:{}}) now at least gets a NOTICE on every refused action, and resolves outright for the recognised shapes.
  7. Marker guard end-to-end. #[ResponseResource] precedence is positional in locate() (attribute checked before the signature/body reads) — pinned by a body-vs-attribute feature test. The feat(#14): infer response schemas from response()->json([...]) literals #235 PrimaryResponseAuthoringAttribute guard in InlineJsonResponseResolver is unaffected: base-resource return types already fail its returnTypeAllowsBodyScan, so the two scanners cannot double-claim.
  8. Lint interplay. resource.response-ambiguous consults the same locator and stops firing for body-resolved collections; resource.fields-undeclared / resource.field-type-missing gained a guard for the new model-only targets (previously impossible: resourceClass was never null past the ambiguity check).

Controlled Vito experiment (clean tree, base 8566e71 vs PR head)

Procedure: stashed the Vito annotation scratch, ran the dogfood harness against the base symlink, repointed vendor/radiergummi/laravel-openapi at this worktree, re-ran, restored everything (symlink → review-251, stash popped, harness re-run to leave consistent artifacts).

base PR
gen_exit / paths 0 / 311 0 / 311
ops that gained a 2xx schema +42 — exactly the #106 prediction (26 under /api, 16 on web /…/json routes outside the survey prefix)
ops whose existing schema changed 0
resource.response-ambiguous 42 0
other lint rules path.parameter-undeclared 4, response.no-error 4, server.invalid-url 1, throws.transitive-missing 6 byte-identical

Envelope spot-checks against real bodies: ProjectResource::collection(user()->projects()->get()) → plain {data} ✓; ServerResource::collection($project->servers()->simplePaginate(25)){data, links, meta} ✓; both behind $this->authorize(...) guard statements the scan correctly walks past.

Gates

  • composer test: 2225 passed (5555 assertions). (One first-run parallel flake in the fixture-copy path, the same pre-existing one noted in the feat(#12): infer Resource response schemas from the toArray() array literal #247 review; green on re-run.)
  • composer lint (PHPStan level 8): no errors.
  • vendor/bin/pint --test: passed.
  • examples/api-resources snapshot diff is exactly the new annotation-free GET /api/bookings operation; all 24 snapshot tests green.

🤖 Generated with Claude Code

@Radiergummi

Copy link
Copy Markdown
Owner Author

Review #253 — empirical (worktree @ 7442905, diff vs 8566e71)

Gates (run locally): composer test 2225 passed (5555 assertions) · composer lint (PHPStan L8) no errors · vendor/bin/pint --test passed.

Verdict: APPROVE — findings below are minor/informational; none blocks the merge. The tier boundary holds under adversarial probing, the survey shows exactly the predicted Vito gain with zero regressions corpus-wide, and the recorded scope decisions all check out empirically.

Findings

  1. MINOR — concreteResourceClass() accepts the base JsonResource itself (and abstract subclasses). Probe: return JsonResource::collection($models); behind an AnonymousResourceCollection signature resolves resourceClass = JsonResource::class, paginated: false instead of refusing — the op silently loses its resource.response-ambiguous finding (and resource.fields-undeclared deliberately skips the base class), while gaining only {data: [$ref → empty JsonResource component]}. Same for AbstractX::collection(). Pre-PR both were flagged ambiguous; post-PR they're silent-empty. Zero hits in the 11-app corpus (grep JsonResource::collection( — none), so theoretical today, but the guard is one line: reject $candidate === JsonResource::class (and arguably isAbstract()) so those shapes refuse with the NOTICE. Fine as an immediate follow-up.
  2. MINOR — paginator-preserving chain links flip the envelope. X::collection($q->paginate(10)->withQueryString())endsInPaginatingCall() sees withQueryString → documents the plain {data} envelope while the runtime response carries links/meta. withQueryString()/appends() preserve the paginator the way ->additional() preserves the resource — consider the symmetric whitelist on the source side. (The opaque-receiver case — Bagisto's /api/products, where $products comes from a repository and is in fact a LengthAwarePaginator — also documents plain {data}; that one is squarely inside decision 4's "never guess meta" and I think the right call, since under-description without additionalProperties: false stays valid.)
  3. INFO — cursorPaginate honesty. Probe confirms cursorPaginate()paginated: true → the length-aware {data, links, meta} shape. Decision 4's "optional properties keep it valid" holds, but the doc then claims meta.current_page/last_page/total that a cursor response never emits and omits next_cursor/prev_cursor. Since the three variants are statically distinguished already, a dedicated cursor (and simple-paginate) envelope is cheap future fidelity — not for this PR.
  4. INFO — resolution without a readable toArray() yields a visible-but-empty schema. Koel's one predicted op (POST /api/songs/by-ids) resolves SongResource (ambiguous 1 → 0) but its toArray() is dynamic → {data: [empty $ref]} + resource.fields-undeclared. Signal moves to the right rule — fine. The survey metric ticking down (170 → 169) on this strict improvement is a harness artifact, filed as survey: metrics.php counts a contentless 2xx as a "response schema", so adding a real-but-empty schema *decreases* responseSchemas #254.
  5. NIT — two dead-code top-level returns (return A…; return B…;) refuse with the "sits beside conditional returns" wording; technically it's a second unconditional return. Cosmetic.
  6. NIT — docs/linting.md row for resource.response-ambiguous still reads "no #[ResponseResource] naming its item class" — now also satisfiable by #[Collects]/$collects/the return expression.

Filed from pre-existing territory the review touched: #254 (survey metric artifact), #255 (single base-JsonResource empty {data:{}} has no lint finding — the AdvisingApp latent sub-bug; this PR's NOTICE narrows but doesn't close it).

Tier-boundary probes (reader driven directly; all behave)

Probe Result
early-return guard (if (!$x) return response()->json(…); return X::collection(…)) REFUSED via two-method-level-returns — never picks the guard's return; observed in the wild (Bagisto BookingProductController::config, NOTICE present)
ternary / match return REFUSED (unrecognised shape)
$x = A…; $x = B…; return $x / assign-inside-if-return-outside REFUSED (not exactly-once-unconditional)
closure containing return before the real return resolves (closure scope correctly excluded)
aliased import / parent-class static call resolve via NameResolver (BaseResource::collection()BaseResource, faithful — that is what ::collection collects)
self::collection() in a non-resource class REFUSED
…->response()->setStatusCode(202) REFUSED + NOTICE (and unreachable via the entry condition anyway)
paginate($request->integer('per_page')) (dynamic arg) paginated ✓
bare ->toResource() on a model with no conventional resource graceful REFUSAL + NOTICE naming the model
new JsonResource($nonModelParam) REFUSED
PHPDoc generic vs disagreeing body PHPDoc wins (also pinned by test)
memoisation refusal NOTICE once per run ✓

Wiring checked: the locator's only construction sites (container scoped binding → autowired #[Scoped] reader; create() in the three lint-rule tests) are all updated; per-run caches sit behind the scoped lifecycle (Octane-safe); the model-target $ref path mirrors EloquentModelResponseResolver's qualifyKey(build()) exactly; the two lint-rule null-guards are semantically right (the model-only target legitimately has no resource to inspect). Example/docs/CHANGELOG diff is exactly the feature.

Survey — Layer A, serialized (#233 repair, per-app --only, links verified review-108 on all 11)

Baseline results-pr-251.json verified: generated today against 8566e71 (= this PR's merge base). PR run → results-pr-253.json.

App apiOps respSchemas completeness % response-ambiguous gen_exit
AdvisingApp 31 31 → 31 96.8 → 96.8 0 → 0 0 → 0
AureusERP 90 90 → 90 91.1 → 91.1 0 → 0 0 → 0
Bagisto 41 13 → 22 29.3 → 51.2 0 → 0 0 → 0
BookStack 86 86 → 86 94.2 → 94.2 0 → 0 0 → 0
Coolify 154 154 → 154 55.8 → 55.8 0 → 0 0 → 0
InvoiceNinja 522 519 → 519 88.3 → 88.3 0 → 0 0 → 0
Koel 170 170 → 169* 82.4 → 82.4 1 → 0 0 → 0
Lychee 205 202 → 202 97.6 → 97.6 0 → 0 0 → 0
Pelican 158 158 → 158 96.2 → 96.2 1 → 1 0 → 0
SpeedtestTracker 8 8 → 8 100 → 100 0 → 0 0 → 0
Vito (corpus tree, annotated scratch) 144 144 → 144 87.5 → 87.5 25 → 0 0 → 0

* #254. Only other lint movement anywhere: resource.fields-undeclared +1 on Bagisto and Koel (newly reached resources with unreadable toArray() — correct). All other rules byte-identical per app. No crashes, no stderr.

Prediction (#106, minus the overcount caveat) vs actual:

  • Vito: exact. Independently re-ran the controlled clean-tree experiment (scratch stashed, base 8566e71 vs PR head, both regenerated): +42 ops gained a substantive 2xx (26 under /api, 16 web /…/json), 0 lost, 0 existing schemas changed, ambiguous 25 → 0 in-tree. Matches the PR-body experiment line for line. The 16 web-route gains sit outside the survey prefix and do not pollute apiOperations/responseSchemas (negative check ✓). On the corpus tree the in-prefix ops were already attribute-annotated (full-spec-proof scratch) and none of their schemas changed — a nice in-the-wild attribute-precedence confirmation.
  • Bagisto: +14 (9 in-prefix, 5 /admin web) vs "up to 34". The feat(#12): infer Resource response schemas from the toArray() array literal #247 caveat confirmed: per-op base-vs-PR spec diff shows 14 gained / 0 lost / 0 changed; 31 distinct actions refused with NOTICEs (54× conditional-returns, 30× no-top-level-return-in-window, 102× new JsonResource(<unknown>) across accumulated runs) — OAPI-017 opportunity sizing across 11 OSS apps — prioritises #12 + an under-tracked lever-2 (body-level Resource type resolution) #106 counted call-shape presence anywhere in the body, not single-unconditional-return reachability. The feared Bagisto failure modes did not materialize: no crashes, no exotic-shape misresolutions, NOTICE volume bounded (once per action·run).
  • Koel: +0 substantive vs +1 — the class resolves, the fields don't (finding 4). Honest outcome.

Spot-checks against source (item schema · envelope · pagination): Bagisto GET /api/products (ProductResource::collection($products) from repository → rich 11+-prop item ✓, plain {data} — under-claims runtime pagination, see finding 2) · GET /api/categories/tree (repository tree → plain {data} ✓ correct) · GET /api/customer/addresses (relation collection → plain {data} ✓ correct) · Koel POST /api/songs/by-ids (collection via overridden SongResource::collection() → still the right item class ✓, plain {data} ✓ — getMany() returns a Collection) · Vito GET /api/projects (->get() → plain ✓) and …/cron-jobs (simplePaginate(25){data, links, meta} ✓), both resolving past authorize()/validateRoute() guard statements.

Net: +51 substantive operations corpus-wide (35 in-prefix equivalent on clean trees), zero regressions, exactly the multiplier #106 promised — the #12 reader now fires on collection endpoints it could never reach. Good ship.

🤖 Generated with Claude Code

@Radiergummi

Copy link
Copy Markdown
Owner Author

Review polish landed in 543a1e2 — per finding:

Finding 1 (base/abstract resource classes accepted) — Fixed. concreteResourceClass() now also rejects $candidate === JsonResource::class and any abstract subclass (via ReflectionClass::isAbstract()); the existing is_a(..., ResourceCollection::class) check already covered ResourceCollection/AnonymousResourceCollection themselves. The probe shapes now refuse through the static-call NOTICE path and the op keeps its resource.response-ambiguous finding. Pinned three ways: unit refusal+NOTICE for JsonResource::collection(...), unit refusal+NOTICE for an abstract-subclass ::collection(...), and a feature test asserting resource.response-ambiguous still fires for the base-class body. The guard applies at the single choke point, so the @return generic, X::class literal-argument, and #[UseResource]/guessResourceName() paths inherit it.

Finding 2 (paginator-preserving chain links flip the envelope) — Fixed. endsInPaginatingCall() now unwraps a whitelist of paginator-preserving links before checking for the paginate() family: withQueryString(), appends(), withPath(), fragment() — the chainable-returning-$this URL/metadata tweaks present on both AbstractPaginator and AbstractCursorPaginator. through() is deliberately excluded (item-mapping, may change what each element looks like), as are the set* setters; a non-whitelisted trailing call keeps the conservative plain-{data} fallback. Pinned: paginate(10)->withQueryString()paginated: true, and paginate(10)->through(...) → plain envelope. Since the unwrap lives in endsInPaginatingCall(), the ->toResourceCollection() receiver path benefits too. Agreed on the opaque-receiver case (Bagisto /api/products) staying plain — that's decision 4 territory.

Nit 5 (refusal-note wording) — Fixed; the note now reads "is not the method's only return, so the resource type would be a guess", accurate for both conditional siblings and dead-code double returns.

Nit 6 (docs/linting.md row) — Fixed; the resource.response-ambiguous description (rule description() and the catalog row, kept in sync) now names all three satisfiers: #[ResponseResource], the collection's #[Collects]/$collects, and the return expression.

Findings 3 (cursor-envelope fidelity) and 4 (resolved-but-empty schema, #254/#255) left as filed follow-ups per the review.

Gates: composer test 2230 passed (5566 assertions), composer lint no errors, vendor/bin/pint --test passed; --group=snapshot 9 passed, no example-spec drift.

🤖 Generated with Claude Code

Radiergummi and others added 4 commits June 12, 2026 13:43
…controller return expression

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…return expression

When the signature names only a base resource type (JsonResource, bare
ResourceCollection, AnonymousResourceCollection), ResourceClassLocator now
consults the new ReturnExpressionResourceReader — a Tier-1 bounded scan of
the method's return expression (first 10 statements, conditional contexts
refused):

- X::collection(...) / X::make(...) / new X(...) name the concrete resource;
  the two-statement $x = ...; return $x; form resolves through the single
  unconditional assignment.
- ->toResource(X::class) / ->toResourceCollection(X::class) take the literal
  class argument; bare $model->toResource() on a Model-typed parameter
  resolves Laravel's own convention (#[UseResource], guessResourceName()).
- new JsonResource($model) on a Model-typed parameter becomes a
  wrapped-model target: the response documents the model schema directly
  (ResourceTarget gains modelClass).
- A @return …Collection<FooResource> docblock generic is read first and wins
  over the body.
- A collection only claims the paginated {data, links, meta} envelope when
  its source visibly ends in a paginate()-family call; otherwise the new
  plain {data} envelope (ResourceEnvelopeFactory::unpaginatedCollection)
  documents it.
- #[ResponseResource] continues to win; refusals keep today's behaviour
  plus one generation-log NOTICE per action and run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The api-resources flavor gains an annotation-free BookingController::index()
returning BookingResource::collection(Booking::query()->latest()->paginate())
behind an AnonymousResourceCollection signature — the snapshot diff is exactly
the new /api/bookings operation with the resolved item $ref and paginated
envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ving chain links

Finding 1: concreteResourceClass() now rejects the base JsonResource itself
and abstract subclasses, so JsonResource::collection(...) / AbstractX::collection(...)
refuse with the NOTICE and keep the resource.response-ambiguous signal alive
instead of silently documenting an empty {data} schema.

Finding 2: endsInPaginatingCall() looks through the paginator-preserving chain
links withQueryString()/appends()/withPath()/fragment() (all return $this and
only tweak generated URLs), so paginate(...)->withQueryString() keeps the
{data, links, meta} envelope; item-mapping through() stays excluded and falls
back to the plain envelope.

Nits: the multiple-returns refusal note no longer claims "conditional" for
dead-code double returns; the resource.response-ambiguous description (rule +
docs/linting.md row) now names all three satisfiers (#[ResponseResource],
#[Collects]/$collects, the return expression).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Radiergummi Radiergummi force-pushed the feat/108-return-expression-resource-type branch from 02ea4e1 to 817fbed Compare June 12, 2026 11:45
@Radiergummi Radiergummi enabled auto-merge (squash) June 12, 2026 11:45
@Radiergummi Radiergummi merged commit f25236c into main Jun 12, 2026
9 checks passed
@Radiergummi Radiergummi deleted the feat/108-return-expression-resource-type branch June 12, 2026 11:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:plugins Bundled convention plugins area:responses Response-body inference enhancement New feature or request tier-1 Tier 1 — bounded AST whitelist

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tier-1: resolve concrete Resource/model type from the controller return expression (X::collection(), ->toResource(), new JsonResource($typed))

1 participant