No class names over the wire
Most host apps have no morph map, so a *_type column is App\Models\User — and shipping it leaks the host’s namespace layout into its API and ties the API to it. Render each related model through a reference resource holding {id, type: class_basename} instead, and pin it with toExposeNoClassNames() — or Assert::exposesNoClassNames() in plain PHPUnit:
use Illuminate\Database\Eloquent\Relations\Relation;
use RoundlyConsulting\Testing\Assert;
it('renders the author without a class name', function (): void {
Relation::morphMap([], merge: false); // no morph map, like most hosts
$resource = new CommentResource($comment->load('author'));
expect($resource)->toExposeNoClassNames(); // the response body, wrapping included
expect($this->getJson('/comments'))->toExposeNoClassNames(); // a TestResponse
expect($payload)->toExposeNoClassNames(); // an array or a JSON string
Assert::exposesNoClassNames($payload); // plain PHPUnit
});What it reads
| Subject | Read as |
|---|---|
| JSON string | Decoded; a string that is not JSON throws InvalidArgumentException. |
| Array | The payload as it is. |
JsonResource | toResponse(request()) — the body a client receives, with data wrapping, with() and additional(). |
Response / JsonResponse / TestResponse | The response body, decoded. |
Anything else — an int, a plain object — throws InvalidArgumentException.
What fails
Every key and every string value is checked, at any depth, and every leak is listed with its path (data.author.type), not only the first:
- A namespaced class name anywhere — App\Models\User, \App\Models\User, a JSON-encoded copy inside a string, one inside prose (No query results for model [App\Models\User]), one used as a key. A StudlyCase Vendor\Name shape is enough; a lowercase chain counts when the class really exists.
- A *_type key holding a class, reported as a morph type column — including a global class with no namespace to give it away ("subject_type": "ArrayObject").
- Nothing to check. An empty string, {}, [], null, an empty resource or a response with no body fails: a payload with no value in it proves nothing rendered.
// Passes — a reference resource's basename, and morph aliases
expect(['id' => 1, 'type' => 'User'])->toExposeNoClassNames();
expect(['subject_type' => 'post', 'payment_type' => 'card'])->toExposeNoClassNames();
// Fails — every leak listed with its path
expect(['data' => ['author' => ['id' => 1, 'type' => 'App\Models\User']]])->toExposeNoClassNames();
// `data.author.type` holds the class name `App\Models\User`
expect(['commentable_type' => 'App\Models\Post', 'commentable_id' => 3])->toExposeNoClassNames();
// the morph type column `commentable_type` holds the class name `App\Models\Post`
expect(['subject_type' => 'ArrayObject'])->toExposeNoClassNames();
// the morph type column `subject_type` holds the class name `ArrayObject`
expect(['message' => 'No query results for model [App\Models\User] 5'])->toExposeNoClassNames();
// `message` holds the class name `App\Models\User`
// Fails — nothing to check
expect([])->toExposeNoClassNames();
// There is nothing to check: the payload is empty, so "it exposes no class names" would pass
// without anything having rendered. Assert on the populated payload.What passes
- A reference resource’s own {"id": 1, "type": "User"} — type is not a *_type column and a basename has no namespace.
- A morph alias in a *_type column ("commentable_type": "post").
- Scalars, null values, URLs and paths with forward slashes.
Limits
- A Windows path (C:\Users\Jane) looks like a class name and fails — a leak of its own.
- It checks what it is given. Internal round-trip formats — exports, ledger reports, queued payloads — are exempt from the rule; don’t point it at them.
- It does not set up the no-morph-map condition for you: clear the morph map in the test, or the payload may hold an alias that hides what a host without one would see.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.