You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When requesting ?related=vendor.contacts on an index endpoint, contacts are only serialized for the first occurrence of each vendor. If multiple parent records (e.g., InvoicePayments) reference the same vendor, the 2nd+ occurrences get the vendor serialized without its nested relationships.
Root Cause
RelatedDto::$resolvedRelationships is a request-scoped singleton that tracks which entity+relation combinations have been serialized,
using the key {uriKey}{primaryKey}{relation} (e.g., vendors5contacts). This was designed to prevent circular references (e.g., users.companies.users creating an infinite loop).
The problem: resolvedRelationships is global across all index items. When PHP's json_encode serializes the response, it processes each item's nested Repository objects sequentially. The first vendor's contacts resolution adds vendors5contacts to the list. When the second vendor (same ID, different parent) tries to resolve contacts, unserialized() finds it already in the list and filters it out.
Why these specific changes
ScopedRelatedItem wrapper (new class) — Wraps each index item in a JsonSerializable that clears resolvedRelationships before json_encode processes that item's nested repos. This scopes the deduplication to each top-level item: circular references within one item are still prevented, but the same child entity under different parents gets a fresh slate.
.all() on the data collection — Critical. Without this, Collection::jsonSerialize() calls jsonSerialize() on ALL ScopedRelatedItem wrappers at once (via array_map ), collecting the unwrapped results into an array BEFORE json_encode processes any nested repos. The .all() converts to a raw PHP array so json_encode processes each item sequentially — calling ScopedRelatedItem::jsonSerialize() (which clears) → fully serializing that item's nested repos → then moving to the next item.
Segment-by-segment dot-notation validation in prepareRelations() — $query->getRelation('vendor.contacts') fails because Eloquent's getRelation() doesn't handle dot notation (it tries to call $model->{'vendor.contacts'}() ). This silently filtered dot-notation relations out of the with() call, causing N+1 queries for nested relations. The fix validates each segment independently (vendor → valid, contacts on vendor's model → valid), while keeping the full dot string for $query->with() which does handle dots natively.
Alternatives considered and rejected
spl_object_id in uniqueIdentifierForRepository() — Each Repository instance gets a unique PHP object ID. This fixes the dedup issue but breaks circular reference protection because circular entities wrapped in new Repository objects have different spl_object_id values → infinite recursion → stack overflow.
Ancestor chain (push/pop in jsonSerialize() ) — Track which entities are "currently being serialized" via a stack. Block an entity if it appears in its own ancestor chain. Fails because jsonSerialize() returns an array with nested Repository objects that are serialized after the parent's jsonSerialize() returns (and pops). The ancestor is no longer on the stack when descendants serialize.
Clearing resolvedRelationships in the performIndexAsArray() map — Runs before json_encode , so by the time nested repos serialize, the cleared state is stale (all items' top-level relations have already added their entries).
Introduction of a new code module
A new class called 'ScopedRelatedItem' has been added, which redefines how certain data sets are interpreted and structured.
Update to existing database functionality
There's a modification to the 'Repository' component to now utilize the newly introduced 'ScopedRelatedItem' class. This alters how the data is structured for easy retrieval.
Improvement of data linking processes
The logic that prepares relations among data entities in the 'RepositorySearchService' has been further refined. This strengthens the validation process for nested relations, making sure that all associations within a data chain are validated.
Addition of a new verification test
A new test has been introduced to confirm that interconnected data entries load correctly, especially for those having the same underlying linkage. This ensures the nested relations function as expected.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Bug
When requesting
?related=vendor.contactson an index endpoint, contacts are only serialized for the first occurrence of each vendor. If multiple parent records (e.g., InvoicePayments) reference the same vendor, the 2nd+ occurrences get the vendor serialized without its nested relationships.Root Cause
RelatedDto::$resolvedRelationshipsis a request-scoped singleton that tracks which entity+relation combinations have been serialized,using the key
{uriKey}{primaryKey}{relation}(e.g.,vendors5contacts). This was designed to prevent circular references (e.g.,users.companies.userscreating an infinite loop).The problem:
resolvedRelationshipsis global across all index items. When PHP's json_encodeserializes the response, it processes each item's nested Repository objects sequentially. The first vendor's contacts resolution addsvendors5contactsto the list. When the second vendor (same ID, different parent) tries to resolve contacts,unserialized()finds it already in the list and filters it out.Why these specific changes
ScopedRelatedItemwrapper (new class) — Wraps each index item in a JsonSerializable that clearsresolvedRelationshipsbeforejson_encodeprocesses that item's nested repos. This scopes the deduplication to each top-level item: circular references within one item are still prevented, but the same child entity under different parents gets a fresh slate..all()on the data collection — Critical. Without this,Collection::jsonSerialize()callsjsonSerialize()on ALLScopedRelatedItemwrappers at once (viaarray_map), collecting the unwrapped results into an array BEFOREjson_encodeprocesses any nested repos. The.all()converts to a raw PHP array sojson_encodeprocesses each item sequentially — callingScopedRelatedItem::jsonSerialize()(which clears) → fully serializing that item's nested repos → then moving to the next item.Segment-by-segment dot-notation validation in
prepareRelations()—$query->getRelation('vendor.contacts')fails because Eloquent'sgetRelation()doesn't handle dot notation (it tries to call$model->{'vendor.contacts'}()). This silently filtered dot-notation relations out of the with() call, causing N+1 queries for nested relations. The fix validates each segment independently (vendor → valid, contacts on vendor's model → valid), while keeping the full dot string for$query->with() which does handle dots natively.Alternatives considered and rejected
spl_object_idinuniqueIdentifierForRepository()— Each Repository instance gets a unique PHP object ID. This fixes the dedup issue but breaks circular reference protection because circular entities wrapped in new Repository objects have differentspl_object_idvalues → infinite recursion → stack overflow.jsonSerialize()) — Track which entities are "currently being serialized" via a stack. Block an entity if it appears in its own ancestor chain. Fails becausejsonSerialize()returns an array with nested Repository objects that are serialized after the parent'sjsonSerialize()returns (and pops). The ancestor is no longer on the stack when descendants serialize.resolvedRelationshipsin theperformIndexAsArray()map — Runs before json_encode, so by the time nested repos serialize, the cleared state is stale (all items' top-level relations have already added their entries).