nld-labs/laravel-persist-relationships

Persist an Eloquent model and sync relationships atomically.

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/nld-labs/laravel-persist-relationships

0.1.0 2026-02-06 18:22 UTC

This package is auto-updated.

Last update: 2026-02-06 18:28:19 UTC


README

Persist an Eloquent model and sync relationships atomically in a single transaction.

Installation

composer require nld-labs/laravel-persist-relationships

Requirements

  • PHP 8.2 or higher
  • Laravel 11.0 or 12.0

Overview

This package provides a clean way to persist Eloquent models along with their relationships in a single atomic transaction. It handles creating/updating parent models and synchronizing HasMany, MorphMany, and BelongsToMany relationships with proper ownership validation and timestamp management.

Key Features

  • Atomic Operations: All changes happen within a database transaction
  • Flexible Sync Strategies: Choose between bulk operations (fast) or Eloquent saves (fires events)
  • Ownership Protection: Prevents unauthorized modification of related records
  • Smart Timestamp Handling: Automatically manages created_at and updated_at columns
  • Configurable Deletion: Control whether missing related records should be deleted or preserved
  • Pivot Support: Handle pivot attributes for many-to-many relationships

Usage

Basic Example

use NLD\PersistRelationships\PersistWithRelationships;

$persister = new PersistWithRelationships();

$post = $persister->run(
    model: new Post(),
    validated: [
        'title' => 'My Blog Post',
        'comments' => [
            ['body' => 'First comment'],
            ['body' => 'Second comment'],
        ],
        'tags' => [1, 2, 3],
    ],
    relationships: [
        'comments' => [
            'columns' => ['body'],
        ],
        'tags' => [],
    ]
);

HasMany Relationships

Creating a Model with HasMany Children

$post = $persister->run(new Post(), [
    'title' => 'My Post',
    'comments' => [
        ['body' => 'First comment'],
        ['body' => 'Second comment'],
    ],
], [
    'comments' => ['columns' => ['body']],
]);

Updating Existing Children and Adding New Ones

$post = $persister->run($existingPost, [
    'title' => 'Updated Post',
    'comments' => [
        ['id' => 1, 'body' => 'Updated comment'],
        ['body' => 'New comment'],
    ],
], [
    'comments' => ['columns' => ['body']],
]);

Configuration Options for HasMany

'comments' => [
    'columns' => ['body', 'author_name'],      // Allowed columns to write
    'deleteMissing' => true,                    // Delete children not in payload (default: true)
    'enforceOwnership' => true,                 // Verify children belong to parent (default: true)
    'useEloquent' => false,                     // Use Eloquent saves vs bulk operations (default: false)
]

Performance vs Events Trade-off:

  • useEloquent: false (default): Uses bulk upsert() and insert() for speed, but bypasses model events, observers, and mutators
  • useEloquent: true: Uses individual model save() and create() calls, fires all events and observers, but slower

MorphMany Relationships

MorphMany relationships work identically to HasMany:

$post = $persister->run(new Post(), [
    'title' => 'My Post',
    'notes' => [
        ['content' => 'First note'],
        ['content' => 'Second note'],
    ],
], [
    'notes' => [
        'columns' => ['content'],
        'deleteMissing' => true,
    ],
]);

BelongsToMany Relationships

Simple ID Array

$post = $persister->run($post, [
    'tags' => [1, 2, 3],
], [
    'tags' => [
        'detachMissing' => true,  // Sync (default: true)
    ],
]);

With Pivot Data

$post = $persister->run($post, [
    'tags' => [
        ['id' => 1, 'sort_order' => 10],
        ['id' => 2, 'sort_order' => 20],
        ['id' => 3, 'sort_order' => 30],
    ],
], [
    'tags' => [
        'detachMissing' => true,      // Sync vs syncWithoutDetaching
        'pivot' => ['sort_order'],     // Allowed pivot columns
    ],
]);

Configuration Options:

  • detachMissing: true - Uses sync() to detach missing relationships (default)
  • detachMissing: false - Uses syncWithoutDetaching() to preserve existing relationships

Clearing Relationships

Pass null as the relationship payload to clear all related records:

$post = $persister->run($post, [
    'comments' => null,  // Deletes all comments
    'tags' => null,      // Detaches all tags
], [
    'comments' => ['columns' => ['body']],
    'tags' => [],
]);

Skipping Relationships

Simply omit the relationship key from the validated data to leave it untouched:

// Only updates title, leaves comments unchanged
$post = $persister->run($post, [
    'title' => 'Updated Title',
    // comments key not present
], [
    'comments' => ['columns' => ['body']],
]);

Ownership Protection

By default, enforceOwnership: true prevents updating children that don't belong to the parent model:

$post1 = Post::create(['title' => 'Post 1']);
$post2 = Post::create(['title' => 'Post 2']);
$comment = Comment::create(['post_id' => $post2->id, 'body' => 'Belongs to post 2']);

// This will ignore the comment since it doesn't belong to $post1
$persister->run($post1, [
    'comments' => [
        ['id' => $comment->id, 'body' => 'Try to steal'],
    ],
], [
    'comments' => [
        'columns' => ['body'],
        'enforceOwnership' => true,  // default
    ],
]);

Set enforceOwnership: false to disable this protection if needed.

Timestamp Handling

The package automatically manages timestamps:

  • New records: Sets both created_at and updated_at
  • Updated records: Only updates updated_at, preserves original created_at
  • Models without timestamps: Skips timestamp columns entirely

This works correctly for both bulk and Eloquent modes.

Complete Example

use NLD\PersistRelationships\PersistWithRelationships;

class PostController extends Controller
{
    public function store(StorePostRequest $request)
    {
        $persister = new PersistWithRelationships();

        $post = $persister->run(
            model: new Post(),
            validated: $request->validated(),
            relationships: [
                'comments' => [
                    'columns' => ['body', 'author_name'],
                    'deleteMissing' => true,
                    'enforceOwnership' => true,
                    'useEloquent' => false,
                ],
                'notes' => [
                    'columns' => ['content'],
                    'deleteMissing' => true,
                ],
                'tags' => [
                    'detachMissing' => true,
                    'pivot' => ['sort_order'],
                ],
            ]
        );

        return new PostResource($post);
    }

    public function update(UpdatePostRequest $request, Post $post)
    {
        $persister = new PersistWithRelationships();

        $post = $persister->run(
            model: $post,
            validated: $request->validated(),
            relationships: [
                'comments' => [
                    'columns' => ['body', 'author_name'],
                ],
                'tags' => [
                    'pivot' => ['sort_order'],
                ],
            ]
        );

        return new PostResource($post);
    }
}

Testing

composer test

License

The MIT License (MIT). Please see License File for more information.

Credits

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.