Background
Precondition for #501 (13 Laravel config keys fold into gesso.php) and groundwork for #506 steps 1-2.
AGENTS.md states: "Keep policy in the core when it is not framework-specific." Three helpers currently violate that by existing twice, byte-for-byte, and a fourth decision is duplicated across two Artisan commands with different results.
1. Three verbatim Laravel ↔ Symfony duplications
extractOrRecordBaselineViolation() — src/Laravel/ValidatesOpenApiSchema.php:1134-1169 vs src/Symfony/OpenApiAssertions.php:389-424. 36 lines each plus a 25-line identical docblock (:1109-1133 / :364-388); diff reports zero differences.
- Result-assertion tail —
ValidatesOpenApiSchema.php:1315-1352 vs Symfony/OpenApiAssertions.php:543-580: diff reports exactly one differing line, the method name. src/Psr7/OpenApiAssertions.php:224-265 is the third copy.
- curl reproduction command —
ValidatesOpenApiSchema.php:786-810 vs Symfony/OpenApiAssertions.php:325-349, differing only in comment wrapping. The Laravel copy also takes an HttpFoundation\Request, so there is no framework-type obstacle.
Constraint: docs/versioning.md:17-18 freezes private members of public traits, so the existing names must survive as as aliases or forwarders.
failOpenApi() / assertOpenApi() are not in scope — #506 already owns them.
2. Laravel config readers, one of which disagrees with itself
Same is_array → per-index is_string → per-index non-empty shape, three times:
resolveSkipResponseCodes() — ValidatesOpenApiSchema.php:1184-1215
resolveSkipRequestValidationResponseCodes() — :1218-1252
resolveAcknowledgedUnvalidatableSchemes() — :1076-1106
Three distinct messages, but parameterising two nouns preserves the prose exactly.
Worse, spec_base_path resolution produces different answers depending on which Artisan command you run:
src/Laravel/Commands/OpenApiRoutesCommand.php:155-190 handles UNC paths and drive letters
src/Laravel/Commands/OpenApiStubsCommand.php:120-127 tests str_starts_with($basePath, '/') and nothing else
default_spec is likewise validated twice (OpenApiRoutesCommand.php:142-147, OpenApiStubsCommand.php:111-117).
Do not add a "Laravel config reader" class. The trait fails through failOpenApi() while the commands throw InvalidArgumentException — different failure channels — and #501's destination is GessoConfig, not a new Laravel-local layer. One private method in the trait, one copy across the two commands. That is the whole change.
⚠️ #485 adds two Laravel config keys and lands in the same file. Sequence the two deliberately.
3. The static validator cache
src/Symfony/OpenApiAssertions.php:92-99 already documents the correct shape: instance-scoped rather than static like Laravel's, because PHPUnit constructs a TestCase per test method so no reset hook is needed.
Laravel carries eight statics (ValidatesOpenApiSchema.php:86-99) plus resetValidatorCache():144-154, whose only job is to undo them — about 30 call sites plus examples/pest/tests/TestCase.php:57. UPGRADING.md:502 explicitly places it outside the frozen surface and it is absent from tests/fixtures/compatibility/v2-public-api.json.
This removes the config-keyed cache logic #501 would otherwise have to carry forward.
Observable change: the current cache key covers only maxErrors / skipCodes (:1010-1030). Per-instance scoping makes a StrictRequiredTracker::current() swapped mid-run newly visible. Gate this on the strict-required run/per-call tests. Do this item last.
Non-goals
- Do not route Laravel/Symfony through the PSR-7 validator. Neither adapter builds PSR-7 messages; both hand HttpFoundation bags straight through (
ValidatesOpenApiSchema.php:684-689, Symfony/OpenApiAssertions.php:222-227). PSR-7 is also the only external caller of validateWithoutRecording() and records coverage itself to promote Skipped → Failure. A bridge plus expanding that tri-state promotion across all paths is roughly +300 lines.
- Do not move
auto_inject_dummy_credentials into the core. ValidatesOpenApiSchema.php:971-981 documents the double match as deliberate, ADR 0005 line 188 already files the key under laravel., and injecting credentials the caller did not send is a verdict change on every adapter.
Acceptance criteria
Related
Background
Precondition for #501 (13 Laravel config keys fold into
gesso.php) and groundwork for #506 steps 1-2.AGENTS.mdstates: "Keep policy in the core when it is not framework-specific." Three helpers currently violate that by existing twice, byte-for-byte, and a fourth decision is duplicated across two Artisan commands with different results.1. Three verbatim Laravel ↔ Symfony duplications
extractOrRecordBaselineViolation()—src/Laravel/ValidatesOpenApiSchema.php:1134-1169vssrc/Symfony/OpenApiAssertions.php:389-424. 36 lines each plus a 25-line identical docblock (:1109-1133/:364-388);diffreports zero differences.ValidatesOpenApiSchema.php:1315-1352vsSymfony/OpenApiAssertions.php:543-580:diffreports exactly one differing line, the method name.src/Psr7/OpenApiAssertions.php:224-265is the third copy.ValidatesOpenApiSchema.php:786-810vsSymfony/OpenApiAssertions.php:325-349, differing only in comment wrapping. The Laravel copy also takes anHttpFoundation\Request, so there is no framework-type obstacle.Constraint:
docs/versioning.md:17-18freezes private members of public traits, so the existing names must survive asasaliases or forwarders.failOpenApi()/assertOpenApi()are not in scope — #506 already owns them.2. Laravel config readers, one of which disagrees with itself
Same is_array → per-index is_string → per-index non-empty shape, three times:
resolveSkipResponseCodes()—ValidatesOpenApiSchema.php:1184-1215resolveSkipRequestValidationResponseCodes()—:1218-1252resolveAcknowledgedUnvalidatableSchemes()—:1076-1106Three distinct messages, but parameterising two nouns preserves the prose exactly.
Worse,
spec_base_pathresolution produces different answers depending on which Artisan command you run:src/Laravel/Commands/OpenApiRoutesCommand.php:155-190handles UNC paths and drive letterssrc/Laravel/Commands/OpenApiStubsCommand.php:120-127testsstr_starts_with($basePath, '/')and nothing elsedefault_specis likewise validated twice (OpenApiRoutesCommand.php:142-147,OpenApiStubsCommand.php:111-117).Do not add a "Laravel config reader" class. The trait fails through
failOpenApi()while the commands throwInvalidArgumentException— different failure channels — and #501's destination isGessoConfig, not a new Laravel-local layer. One private method in the trait, one copy across the two commands. That is the whole change.3. The static validator cache
src/Symfony/OpenApiAssertions.php:92-99already documents the correct shape: instance-scoped rather than static like Laravel's, because PHPUnit constructs aTestCaseper test method so no reset hook is needed.Laravel carries eight statics (
ValidatesOpenApiSchema.php:86-99) plusresetValidatorCache():144-154, whose only job is to undo them — about 30 call sites plusexamples/pest/tests/TestCase.php:57.UPGRADING.md:502explicitly places it outside the frozen surface and it is absent fromtests/fixtures/compatibility/v2-public-api.json.This removes the config-keyed cache logic #501 would otherwise have to carry forward.
Observable change: the current cache key covers only
maxErrors/skipCodes(:1010-1030). Per-instance scoping makes aStrictRequiredTracker::current()swapped mid-run newly visible. Gate this on the strict-required run/per-call tests. Do this item last.Non-goals
ValidatesOpenApiSchema.php:684-689,Symfony/OpenApiAssertions.php:222-227). PSR-7 is also the only external caller ofvalidateWithoutRecording()and records coverage itself to promote Skipped → Failure. A bridge plus expanding that tri-state promotion across all paths is roughly +300 lines.auto_inject_dummy_credentialsinto the core.ValidatesOpenApiSchema.php:971-981documents the double match as deliberate, ADR 0005 line 188 already files the key underlaravel., and injecting credentials the caller did not send is a verdict change on every adapter.Acceptance criteria
spec_base_pathanddefault_specresolve identically in both Artisan commands; a test pins the UNC / drive-letter cases against bothresetValidatorCache()and its ~30 call sites are gone, and the strict-required run/per-call suites passv2-public-api.jsonunchangedcomposer cipassesRelated