Skip to main content

Overview

The ManagesManyToMany trait provides complete CRUD functionality for many-to-many (BelongsToMany) relationships with support for:
  • Reading related records with filtering, pagination, and sorting
  • Creating related records through the relationship
  • Updating related records and pivot data
  • Deleting related records and pivot associations
  • Attaching/detaching existing records
  • Syncing and toggling associations
  • Bulk operations for all mutation methods
  • Excel/PDF export of related data
Namespace: Ronu\RestGenericClass\Core\Traits\ManagesManyToMany Location: /src/Core/Traits/ManagesManyToMany.php:40

Configuration

Basic Setup

Add the trait to your controller and define $manyToManyConfig:
use Ronu\RestGenericClass\Core\Traits\ManagesManyToMany;

class UserController extends RestController
{
    use ManagesManyToMany;
    
    protected array $manyToManyConfig = [
        'addresses' => [
            'relationship'  => 'array_address',      // BelongsToMany method on parent
            'relatedModel'  => Address::class,
            'pivotModel'    => UserAddress::class,
            'parentModel'   => User::class,
            'parentKey'     => 'user_id',
            'relatedKey'    => 'address_id',
            
            'mutation' => [
                'dataKey'       => ['Addresses', 'addresses'],
                'deleteRelated' => true,
                'pivotColumns'  => ['is_primary', 'label'],
            ],
        ],
    ];
}

Configuration Options

Required

  • relationship (string) - Name of the BelongsToMany method on the parent model
  • relatedModel (string) - Fully qualified class name of the related model
  • pivotModel (string) - Pivot table model class
  • parentModel (string) - Parent model class name
  • parentKey (string) - Foreign key column in pivot table for parent
  • relatedKey (string) - Foreign key column in pivot table for related model

Optional (mutation)

  • dataKey (array|string) - Request keys to extract data from (default: [])
  • deleteRelated (bool) - Delete related model on deleteRelation (default: true)
  • pivotColumns (array) - Whitelist of allowed pivot columns (default: [] = all)

Read Operations

listRelation

List related entities with filtering, pagination, and sorting.
public function listRelation(Request $request, mixed $parentId = null): LengthAwarePaginator|array

Parameters

  • Request $request - HTTP request with query parameters
  • mixed $parentId - Parent ID (null = auth user)

Query Parameters

  • select - Columns to select (default: ['*'])
  • relations - Relations to eager-load
  • eq/attr - Equality filters
  • oper - Complex filters (see Filtering)
  • orderby - Sort order
  • pagination - Pagination settings

Example

GET /api/users/1/addresses?select=id,street,city&orderby={"city":"asc"}&pagination={"page":1,"pageSize":20}
Response:
{
  "current_page": 1,
  "data": [
    {
      "id": 5,
      "street": "123 Main St",
      "city": "Boston",
      "pivot": {
        "user_id": 1,
        "address_id": 5,
        "is_primary": true,
        "label": "Home"
      }
    }
  ],
  "per_page": 20,
  "total": 3
}

showRelation

Retrieve a single related entity.
public function showRelation(
    Request $request, 
    mixed $parentIdOrRelatedId, 
    mixed $relatedId = null
): mixed

Parameters

  • Request $request - HTTP request
  • mixed $parentIdOrRelatedId - Parent ID (admin) or related ID (site/mobile)
  • mixed $relatedId - Related ID (admin only)

Route Shapes

// Site/Mobile: uses auth user as parent
GET /addresses/5

// Admin: explicit parent ID
GET /users/1/addresses/5

Returns

  • Related model instance on success
  • 404 JSON response if not found

Example Response (Not Found)

{
  "success": false,
  "error": {
    "message": "Address with id 99 not found in relation 'addresses'",
    "relation": "addresses",
    "id": 99,
    "suggested_fix": "Verify the resource exists via GET on the 'addresses' endpoint before performing write operations on it."
  }
}

Create Operations

createRelation

Create new related entities through the relationship.
public function createRelation(Request $request, mixed $parentId = null): JsonResponse

Single Mode

Route: POST /users/1/addresses Request:
{
  "addresses": {
    "street": "456 Oak Ave",
    "city": "Chicago",
    "is_primary": false
  }
}
Response (201):
{
  "success": true,
  "model": {
    "id": 10,
    "street": "456 Oak Ave",
    "city": "Chicago"
  }
}

Bulk Mode

Set _scenario to any value containing “bulk”: Route: POST /users/1/addresses?_scenario=bulk_create Request:
{
  "addresses": [
    {"street": "789 Elm St", "city": "Austin"},
    {"street": "321 Pine Rd", "city": "Denver"}
  ]
}
Response (201):
{
  "success": true,
  "models": [
    {"id": 11, "street": "789 Elm St", "city": "Austin"},
    {"id": 12, "street": "321 Pine Rd", "city": "Denver"}
  ]
}

Update Operations

updateRelation

Update related entities.
public function updateRelation(
    Request $request, 
    mixed $parentIdOrRelatedId = null, 
    mixed $relatedId = null
): JsonResponse

Single Mode

Route: PUT /users/1/addresses/5 Request:
{
  "street": "Updated Street",
  "city": "New City"
}
Response:
{
  "success": true,
  "model": {
    "id": 5,
    "street": "Updated Street",
    "city": "New City"
  }
}

Bulk Mode

Route: PUT /users/1/addresses?_scenario=bulk_update Request:
{
  "addresses": [
    {"id": 5, "city": "Boston"},
    {"id": 6, "city": "Seattle"}
  ]
}
Response:
{
  "success": true,
  "models": [
    {"id": 5, "city": "Boston"},
    {"id": 6, "city": "Seattle"}
  ]
}

Delete Operations

deleteRelation

Delete related entities and optionally detach pivot.
public function deleteRelation(
    Request $request, 
    mixed $parentIdOrRelatedId = null, 
    mixed $relatedId = null
): JsonResponse

Configuration

'mutation' => [
    'deleteRelated' => true,  // Delete model + detach pivot
    // OR
    'deleteRelated' => false, // Only detach pivot (keep model)
]

Single Mode

Route: DELETE /users/1/addresses/5 Response:
{
  "success": true,
  "model": {
    "id": 5,
    "street": "123 Main St"
  }
}

Bulk Mode

Route: DELETE /users/1/addresses?_scenario=bulk_delete Request:
{
  "address_ids": [5, 6, 7]
}
Or flat array:
[5, 6, 7]
Response:
{
  "success": true,
  "models": [
    {"id": 5},
    {"id": 6},
    {"id": 7}
  ]
}

Attach/Detach Operations

attachRelation

Attach existing related entities (pivot-only operation).
public function attachRelation(Request $request, mixed $parentId = null): JsonResponse

Scenarios

  1. attach - Single ID with optional pivot data
  2. bulk_attach - Multiple IDs with optional pivot data
  3. sync - Replace entire relationship set
  4. toggle - Toggle specific IDs

Single Attach

Route: POST /users/1/addresses/attach?_scenario=attach Request:
{
  "address_id": 10,
  "is_primary": true,
  "label": "Work"
}
Response:
{
  "attached": [10]
}

Bulk Attach

Route: POST /users/1/addresses/attach?_scenario=bulk_attach Request (with pivot data):
{
  "addresses": [
    {"address_id": 11, "is_primary": false, "label": "Home"},
    {"address_id": 12, "is_primary": false, "label": "Office"}
  ]
}
Or flat IDs:
{
  "addresses": [11, 12, 13]
}
Response:
{
  "attached": [11, 12, 13]
}

Sync

Replaces all associations: Route: POST /users/1/addresses/attach?_scenario=sync Request:
{
  "addresses": [
    {"address_id": 5, "is_primary": true},
    {"address_id": 8, "is_primary": false}
  ]
}
Response:
{
  "attached": [8],
  "detached": [6, 7],
  "updated": [5]
}

Toggle

Attach if not present, detach if present: Route: POST /users/1/addresses/attach?_scenario=toggle Request:
{
  "addresses": [5, 10, 15]
}
Response:
{
  "attached": [10],
  "detached": [5, 15]
}

detachRelation

Detach related entities (pivot-only removal).
public function detachRelation(
    Request $request, 
    mixed $parentIdOrRelatedId = null, 
    mixed $relatedId = null
): JsonResponse

Single Detach

Route: DELETE /users/1/addresses/5/detach Response:
{
  "detached": 1
}

Bulk Detach

Route: DELETE /users/1/addresses/detach?_scenario=bulk_detach Request:
[5, 6, 7]
Response:
{
  "detached": 3
}

updatePivotRelation

Update pivot table fields without modifying related model.
public function updatePivotRelation(
    Request $request, 
    mixed $parentIdOrRelatedId = null, 
    mixed $relatedId = null
): JsonResponse

Single Mode

Route: PUT /users/1/addresses/5/pivot Request:
{
  "is_primary": true,
  "label": "Primary Address"
}
Response:
{
  "id": 5,
  "street": "123 Main St",
  "pivot": {
    "is_primary": true,
    "label": "Primary Address"
  }
}

Bulk Mode

Route: PUT /users/1/addresses/pivot?_scenario=bulk_update_pivot Request:
{
  "addresses": [
    {"address_id": 5, "is_primary": true},
    {"address_id": 6, "is_primary": false}
  ]
}

Export Operations

exportRelationExcel

Export related data to Excel.
public function exportRelationExcel(Request $request, mixed $parentId = null): mixed

Parameters

  • filename (string) - Output filename (default: 'export.xlsx')
  • columns (array|CSV) - Explicit columns for spreadsheet
  • select, eq, oper, orderby, relations - Same as listRelation

Example

GET /users/1/addresses/export/excel?filename=user-addresses.xlsx&columns=street,city,postal_code
Requires: maatwebsite/excel package

exportRelationPdf

Export related data to PDF.
public function exportRelationPdf(Request $request, mixed $parentId = null): mixed

Parameters

  • filename (string) - Output filename (default: 'export.pdf')
  • template (string) - Blade view name (default: 'pdf')
  • columns, select, eq, oper, orderby, relations - Same as listRelation

Example

GET /users/1/addresses/export/pdf?filename=addresses.pdf&template=reports.address
Requires: barryvdh/laravel-dompdf package

Pivot Data Handling

The trait supports three input shapes for pivot data:

Shape 1: Flat ID List

[1, 2, 3]
Normalized to:
[1 => [], 2 => [], 3 => []]

Shape 2: Object List

[
  {"address_id": 1, "is_primary": true, "label": "Home"},
  {"address_id": 2, "is_primary": false, "label": "Work"}
]
Normalized to:
[
  1 => ["is_primary" => true, "label" => "Home"],
  2 => ["is_primary" => false, "label" => "Work"]
]

Shape 3: Laravel-Native Map

{
  "1": {"is_primary": true},
  "2": {"is_primary": false}
}
Passed through unchanged.

Pivot Column Whitelist

'mutation' => [
    'pivotColumns' => ['is_primary', 'label', 'order'],
]
Only these columns are accepted. Others are silently ignored.

Error Handling

Not Found (Single)

Returns 404 JSON:
{
  "success": false,
  "error": {
    "message": "Address with id 99 not found in relation 'addresses'",
    "relation": "addresses",
    "id": 99,
    "suggested_fix": "Verify the resource exists via GET on the 'addresses' endpoint before performing write operations on it."
  }
}

Not Found (Bulk)

Partial success with error details:
{
  "success": false,
  "models": [
    {"id": 5, "street": "Found Address"}
  ],
  "error": {
    "message": "2 Address record(s) not found in relation 'addresses': [99, 100]",
    "relation": "addresses",
    "not_found_ids": [99, 100],
    "suggested_fix": "Fetch the current list via GET on the 'addresses' endpoint to obtain valid IDs, then retry with only the existing records."
  }
}

Relation Not Configured

// Throws BadRequestHttpException
"Many-to-many relation 'invalid' is not configured on UserController"

Parent Not Found

// Throws NotFoundHttpException
"User with id 9999 not found"

Query Optimization

The trait implements several optimizations:
  1. Batch loading: Bulk operations load all entities in one query
  2. Batch updates: Uses single detach()/delete() for multiple IDs
  3. Batch refresh: Refreshes all updated entities in one query
  4. Minimal queries: Typical bulk update = 3 queries (load, N updates, refresh)

Build docs developers (and LLMs) love