Home

Awesome

build PHP 8.2/3 Laravel 10/11

Laravel Deep Sync

Elegantly sync properties across any relationship.

Installation

Requirements:

composer require c-tanner/laravel-deep-sync

More than just cascading soft-deletes

Cascading soft-deletes within Laravel has been covered by a number of great packages in the past. At its core, though, deleted_at is just another class property.

While DeepSync does offer native support for cascading / syncing soft-deletes, you can also assign any model property as syncable - and choose which models should follow suit.

Let's take the classic User / Post example:

#[ObservedBy([DeepSync::class])]
class User extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $fillable = [
        'name',
        'is_active'
    ];

    // Properties that trigger DeepSync
    public $syncable = ['is_active'];

    #[SyncTo]
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class, 'author_id');
    }
}

Here, our User model defines it's is_active property as syncable, and that the Post model should SyncTo changes.

Then, in our Post model:

#[ObservedBy([DeepSync::class])]
class Post extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $fillable = [
        'title',
        'body',
        'author_id',
        'is_active'
    ];

    // Properties that trigger DeepSync
    public $syncable = ['is_active'];

    #[SyncFrom]
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class, 'author_id');
    }
}

Note that the Post model must contain the #[SyncFrom] attribute, the is_active class property, and the $syncable array.

Observer events

DeepSync currently supports saved() and deleted() model events. Note that in Laravel, update() also calls save() under the hood, and will also trigger the DeepSync observer.

Polymorphic support

Cascading properties in one-to-one or one-to-many relationships is straightforward: when the "parent" model changes state, DeepSync finds the "child" records using Eloquent relationship methods tagged with the #[SyncTo] attribute and updates the property to the same value. Child models are also inspected for their relationship methods, and the process continues down the tree.

For many-to-many or many-to-one relationships, DeepSync only updates child records if all parents share the same state.

example relationship diagram

In the example above, we can see that when User A is deleted, Post A is also deleted, as User A is it's only parent. Since Post B, even though it also related to User A, is also related to User B, and therefore remains unchanged.

DeepSync relationships cascade, and will traverse to as many levels as are defined:

example multi-level relationship diagram

Though these examples use delete actions for ease of demonstration, these concepts apply to all class properties defined in the syncable array.

Omnidirectional syncs

Because we can define the direction of SyncFrom and SyncTo independent of our actual class hierarchy, a pretty neat feature becomes available.

Let's say we have two models, Task and Subtask. The class hierarchy is as you would expect:

class Task {
    return subtasks(): HasMany
        return $this->hasMany(Subtask::class);
    }
}

However, let's say that both classes have a property, is_complete, which defaults to false, and we want to automatically mark a Task complete only when all related Subtasks are also complete:

example reverse sync diagram

Let's look at how to acheive this in the code:

#[ObservedBy([DeepSync::class])]
class Task extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $fillable = [
        'name',
        'is_complete'
    ];

    public $syncable = ['is_complete'];

    #[SyncFrom]
    public function subtasks(): HasMany
    {
        return $this->hasMany(Subtask::class);
    }
}

Note that we are using the #[SyncFrom] attribute on the "parent" class here instead of #[SyncTo].

And in our Subtask class:

#[ObservedBy([DeepSync::class])]
class Subtask extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $fillable = [
        'name',
        'is_complete',
        'task_id'
    ];

    public $syncable = ['is_complete'];

    #[SyncTo]
    public function task(): BelongsTo
    {
        return $this->belongsTo(Task::class);
    }
}

Now let's test it:

public function test_reverse_sync()
{
    $task = Task::factory()->has(
        Subtask::factory(3)->state(
            function(array $attributes, Task $task) {
                return [
                    'task_id' => $task->id
                ];
            }
        )
    )->create();

    $this->assertEquals(1, Task::count());
    $this->assertEquals(3, Subtask::count());
    $this->assertEquals(3, Task::find($task->id)->subtasks()->count());

    // Task only becomes complete when all subtasks are complete
    
    $subtask1 = Subtask::find(1);
    $subtask1->update(['is_complete' => 1]);

    $this->assertEquals(0, Task::find($task->id)->is_complete);

    $subtask2 = Subtask::find(2);
    $subtask2->update(['is_complete' => 1]);

    $this->assertEquals(0, Task::find($task->id)->is_complete);

    $subtask3 = Subtask::find(3);
    $subtask3->update(['is_complete' => 1]);

    $this->assertEquals(1, Task::find($task->id)->is_complete);
    
}
$ ~/laravel-deep-sync: vendor/bin/phpunit --testsuite=Feature --colors=always         
PHPUnit 11.3.6 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.11
Configuration: /Users/christanner/Code/laravel-deep-sync/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.238, Memory: 38.50 MB

OK (1 test, 6 assertions)

Configuration

Ironically, Observers in Laravel aren't very observable (I think that's what irony is, right?). This can make debugging quite difficult, so DeepSync comes with verbose logging configured by default, output to your application's default log channel. You can turn logging off, or change the log severity by publishing the configuration file:

php artisan vendor:publish --tag=deepsync