NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Testing for Laravel

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

SubjectRead as
JSON stringDecoded; a string that is not JSON throws InvalidArgumentException.
ArrayThe payload as it is.
JsonResourcetoResponse(request()) — the body a client receives, with data wrapping, with() and additional().
Response / JsonResponse / TestResponseThe 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 crypto

By 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.