├── LICENSE ├── README.md ├── canvas.yaml ├── composer.json ├── doc ├── relationship-example-1.png ├── relationship-example-2.png ├── relationship-example-3.png └── relationship-example-multi.png └── src ├── Attributes ├── SyncFrom.php └── SyncTo.php ├── Observers └── DeepSync.php ├── Providers └── DeepSyncProvider.php └── config └── deepsync.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Feross Aboukhadijeh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build](https://github.com/c-tanner/laravel-deep-sync/actions/workflows/build.yml/badge.svg)](https://github.com/c-tanner/laravel-deep-sync/actions/workflows/build.yml) 2 | [![PHP 8.2/3](https://github.com/c-tanner/laravel-deep-sync/actions/workflows/php-compatibility.yml/badge.svg)](https://github.com/c-tanner/laravel-deep-sync/actions/workflows/php-compatibility.yml) 3 | [![Laravel 10/11](https://github.com/c-tanner/laravel-deep-sync/actions/workflows/laravel-compatibility.yml/badge.svg)](https://github.com/c-tanner/laravel-deep-sync/actions/workflows/laravel-compatibility.yml) 4 | 5 | # Laravel Deep Sync 6 | 7 | Elegantly sync properties across any relationship. 8 | 9 | ## Installation 10 | 11 | Requirements: 12 | - PHP >= 8.2 13 | - Laravel >= 10 14 | 15 | `composer require c-tanner/laravel-deep-sync` 16 | 17 | ## More than just cascading soft-deletes 18 | 19 | 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. 20 | 21 | 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. 22 | 23 | Let's take the classic `User` / `Post` example: 24 | 25 | ```php 26 | #[ObservedBy([DeepSync::class])] 27 | class User extends Model 28 | { 29 | use HasFactory; 30 | use SoftDeletes; 31 | 32 | protected $fillable = [ 33 | 'name', 34 | 'is_active' 35 | ]; 36 | 37 | // Properties that trigger DeepSync 38 | public $syncable = ['is_active']; 39 | 40 | #[SyncTo] 41 | public function posts(): HasMany 42 | { 43 | return $this->hasMany(Post::class, 'author_id'); 44 | } 45 | } 46 | ``` 47 | 48 | Here, our `User` model defines it's `is_active` property as `syncable`, and that the `Post` model should `SyncTo` changes. 49 | 50 | Then, in our `Post` model: 51 | 52 | ```php 53 | #[ObservedBy([DeepSync::class])] 54 | class Post extends Model 55 | { 56 | use HasFactory; 57 | use SoftDeletes; 58 | 59 | protected $fillable = [ 60 | 'title', 61 | 'body', 62 | 'author_id', 63 | 'is_active' 64 | ]; 65 | 66 | // Properties that trigger DeepSync 67 | public $syncable = ['is_active']; 68 | 69 | #[SyncFrom] 70 | public function user(): BelongsTo 71 | { 72 | return $this->belongsTo(User::class, 'author_id'); 73 | } 74 | } 75 | ``` 76 | 77 | > Note that the `Post` model must contain the `#[SyncFrom]` attribute, the `is_active` class property, and the `$syncable` array. 78 | 79 | ## Observer events 80 | 81 | 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. 82 | 83 | ## Polymorphic support 84 | 85 | 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. 86 | 87 | For many-to-many or many-to-one relationships, DeepSync only updates child records _if all parents share the same state_. 88 | 89 | ![example relationship diagram](https://github.com/c-tanner/laravel-deep-sync/blob/main/doc/relationship-example-1.png) 90 | 91 | 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. 92 | 93 | DeepSync relationships cascade, and will traverse to as many levels as are defined: 94 | 95 | ![example multi-level relationship diagram](https://github.com/c-tanner/laravel-deep-sync/blob/main/doc/relationship-example-2.png) 96 | 97 | Though these examples use delete actions for ease of demonstration, these concepts apply to all class properties defined in the `syncable` array. 98 | 99 | ## Omnidirectional syncs 100 | 101 | Because we can define the direction of `SyncFrom` and `SyncTo` independent of our actual class hierarchy, a pretty neat feature becomes available. 102 | 103 | Let's say we have two models, `Task` and `Subtask`. The class hierarchy is as you would expect: 104 | 105 | ```php 106 | class Task { 107 | return subtasks(): HasMany 108 | return $this->hasMany(Subtask::class); 109 | } 110 | } 111 | ``` 112 | 113 | 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: 114 | 115 | ![example reverse sync diagram](https://github.com/c-tanner/laravel-deep-sync/blob/main/doc/relationship-example-3.png) 116 | 117 | Let's look at how to acheive this in the code: 118 | 119 | ```php 120 | #[ObservedBy([DeepSync::class])] 121 | class Task extends Model 122 | { 123 | use HasFactory; 124 | use SoftDeletes; 125 | 126 | protected $fillable = [ 127 | 'name', 128 | 'is_complete' 129 | ]; 130 | 131 | public $syncable = ['is_complete']; 132 | 133 | #[SyncFrom] 134 | public function subtasks(): HasMany 135 | { 136 | return $this->hasMany(Subtask::class); 137 | } 138 | } 139 | ``` 140 | 141 | > Note that we are using the `#[SyncFrom]` attribute on the "parent" class here instead of `#[SyncTo]`. 142 | 143 | And in our `Subtask` class: 144 | 145 | ```php 146 | #[ObservedBy([DeepSync::class])] 147 | class Subtask extends Model 148 | { 149 | use HasFactory; 150 | use SoftDeletes; 151 | 152 | protected $fillable = [ 153 | 'name', 154 | 'is_complete', 155 | 'task_id' 156 | ]; 157 | 158 | public $syncable = ['is_complete']; 159 | 160 | #[SyncTo] 161 | public function task(): BelongsTo 162 | { 163 | return $this->belongsTo(Task::class); 164 | } 165 | } 166 | ``` 167 | 168 | Now let's test it: 169 | 170 | ```php 171 | public function test_reverse_sync() 172 | { 173 | $task = Task::factory()->has( 174 | Subtask::factory(3)->state( 175 | function(array $attributes, Task $task) { 176 | return [ 177 | 'task_id' => $task->id 178 | ]; 179 | } 180 | ) 181 | )->create(); 182 | 183 | $this->assertEquals(1, Task::count()); 184 | $this->assertEquals(3, Subtask::count()); 185 | $this->assertEquals(3, Task::find($task->id)->subtasks()->count()); 186 | 187 | // Task only becomes complete when all subtasks are complete 188 | 189 | $subtask1 = Subtask::find(1); 190 | $subtask1->update(['is_complete' => 1]); 191 | 192 | $this->assertEquals(0, Task::find($task->id)->is_complete); 193 | 194 | $subtask2 = Subtask::find(2); 195 | $subtask2->update(['is_complete' => 1]); 196 | 197 | $this->assertEquals(0, Task::find($task->id)->is_complete); 198 | 199 | $subtask3 = Subtask::find(3); 200 | $subtask3->update(['is_complete' => 1]); 201 | 202 | $this->assertEquals(1, Task::find($task->id)->is_complete); 203 | 204 | } 205 | ``` 206 | 207 | ``` 208 | $ ~/laravel-deep-sync: vendor/bin/phpunit --testsuite=Feature --colors=always 209 | PHPUnit 11.3.6 by Sebastian Bergmann and contributors. 210 | 211 | Runtime: PHP 8.3.11 212 | Configuration: /Users/christanner/Code/laravel-deep-sync/phpunit.xml 213 | 214 | . 1 / 1 (100%) 215 | 216 | Time: 00:00.238, Memory: 38.50 MB 217 | 218 | OK (1 test, 6 assertions) 219 | ``` 220 | 221 | ## Configuration 222 | 223 | 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: 224 | 225 | `php artisan vendor:publish --tag=deepsync` 226 | -------------------------------------------------------------------------------- /canvas.yaml: -------------------------------------------------------------------------------- 1 | preset: package 2 | 3 | namespace: CTanner\LaravelDeepSync 4 | 5 | paths: 6 | src: src 7 | resource: resources 8 | 9 | factory: 10 | path: tests/database/Factories 11 | 12 | migration: 13 | path: tests/database/migrations 14 | prefix: '' 15 | 16 | console: 17 | namespace: CTanner\LaravelDeepSync\Console 18 | 19 | model: 20 | namespace: CTanner\LaravelDeepSync\Tests\Models 21 | 22 | provider: 23 | namespace: CTanner\LaravelDeepSync\Providers 24 | 25 | testing: 26 | namespace: CTanner\LaravelDeepSync\Tests -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "c-tanner/laravel-deep-sync", 3 | "description": "Elegantly sync properties across any relationship", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "role": "Developer", 9 | "name": "Chris Tanner", 10 | "email": "ctanneraudio@gmail.com", 11 | "homepage": "https://www.github.com/c-tanner" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "CTanner\\LaravelDeepSync\\": "src/" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "CTanner\\LaravelDeepSync\\Tests\\": "tests/", 22 | "CTanner\\LaravelDeepSync\\Tests\\Database\\": "tests/database/" 23 | } 24 | }, 25 | "require": { 26 | "php": "^8.2", 27 | "illuminate/support": "^9|^10|^11" 28 | }, 29 | "minimum-stability": "dev", 30 | "prefer-stable": true, 31 | "config": { 32 | "sort-packages": true, 33 | "preferred-install": "dist", 34 | "optimize-autoloader": true 35 | }, 36 | "extra": { 37 | "laravel": { 38 | "providers": [ 39 | "CTanner\\LaravelDeepSync\\Providers\\DeepSyncProvider" 40 | ] 41 | } 42 | }, 43 | "require-dev": { 44 | "laravel/pint": "^1.15", 45 | "orchestra/canvas": "^8.11|^9.1", 46 | "orchestra/testbench": "^8.27|^9", 47 | "phpunit/phpunit": "^10.5" 48 | }, 49 | "scripts": { 50 | "post-autoload-dump": [], 51 | "lint": [ 52 | "@php vendor/bin/pint --ansi", 53 | "@php vendor/bin/phpstan analyse --verbose --ansi" 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /doc/relationship-example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-tanner/laravel-deep-sync/244898f29b961fb2d9c884f9f35a3782b0eefed9/doc/relationship-example-1.png -------------------------------------------------------------------------------- /doc/relationship-example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-tanner/laravel-deep-sync/244898f29b961fb2d9c884f9f35a3782b0eefed9/doc/relationship-example-2.png -------------------------------------------------------------------------------- /doc/relationship-example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-tanner/laravel-deep-sync/244898f29b961fb2d9c884f9f35a3782b0eefed9/doc/relationship-example-3.png -------------------------------------------------------------------------------- /doc/relationship-example-multi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c-tanner/laravel-deep-sync/244898f29b961fb2d9c884f9f35a3782b0eefed9/doc/relationship-example-multi.png -------------------------------------------------------------------------------- /src/Attributes/SyncFrom.php: -------------------------------------------------------------------------------- 1 | logEnabled = config('deepsync.logging.enabled'); 26 | $this->logLevel = config('deepsync.logging.log_level'); 27 | 28 | if ($this->logEnabled) { 29 | Context::add('meta', [ 30 | 'source' => get_class($this), 31 | 'traceId' => Str::uuid()->toString() 32 | ]); 33 | } 34 | } 35 | 36 | /** 37 | * Log a message if enabled 38 | * 39 | * @param string $msg The log message 40 | * 41 | * @return void 42 | */ 43 | private function logger(string $msg): void 44 | { 45 | if (!$this->logEnabled) { 46 | return; 47 | } 48 | 49 | Log::{$this->logLevel}($msg); 50 | } 51 | 52 | /** 53 | * Handle state change events 54 | * 55 | * @param Model $triggerObj 56 | * 57 | * @return void 58 | */ 59 | public function saved(Model $triggerObj): void 60 | { 61 | if (!$triggerObj->syncable) { 62 | return; 63 | } 64 | 65 | $this->triggerObj = $triggerObj; 66 | 67 | foreach ($this->triggerObj->syncable as $property) { 68 | 69 | $this->syncProperty = $property; 70 | 71 | // Only trigger if the object is moving to the desired state 72 | if ($this->triggerObj->$property !== $this->triggerObj->getOriginal($property)) { 73 | 74 | $this->logger(get_class($this->triggerObj) ." (ID: {$this->triggerObj->id}) triggered a sync event ($property)"); 75 | 76 | $this->reflectionIterator([$this, 'handleStateChange']); 77 | } 78 | 79 | } 80 | } 81 | 82 | /** 83 | * Handle deletion events 84 | * 85 | * @param Model $triggerObj 86 | * 87 | * @return void 88 | */ 89 | public function deleted(Model $triggerObj): void 90 | { 91 | $this->triggerObj = $triggerObj; 92 | 93 | $this->logger(get_class($this->triggerObj) . " (ID: $triggerObj->id) triggered a deletion event"); 94 | 95 | $this->reflectionIterator([$this, 'handleDelete']); 96 | 97 | } 98 | 99 | /** 100 | * Handles iterating over related parent/child records for reflection-based relationships 101 | * 102 | * @param callable $callback The callback function that executes the modification of the child 103 | * 104 | * @return void 105 | */ 106 | private function reflectionIterator(callable $callback): void 107 | { 108 | $this->logger("Beginning reflectionIterator for ". get_class($this->triggerObj) ." ..."); 109 | 110 | if ($children = $this->getChildrenByReflection($this->triggerObj)) { 111 | 112 | $children->each(function($child) use ($callback) { 113 | 114 | $this->logger('Checking child: ' . json_encode($child)); 115 | 116 | if ($parents = $this->getParentsByReflection($child)) { 117 | 118 | $this->logger('Found parents for child:'); 119 | 120 | $parents->each(function ($item) { 121 | $this->logger(json_encode($item)); 122 | }); 123 | 124 | $callback($parents, $child); 125 | 126 | } else { 127 | 128 | $this->logger('Did not find parents for: ' . json_encode($child)); 129 | 130 | } 131 | }); 132 | } 133 | } 134 | 135 | /** 136 | * Handles deletion children if all parents are deleted 137 | * 138 | * @param Collection $parents A collection of parent models to check 139 | * @param Model $childRecord The related child record to delete 140 | * 141 | * @return void 142 | */ 143 | private function handleDelete(Collection $parents, Model $childRecord): void 144 | { 145 | $parents->filter(function($parent) { 146 | $deletedAtColumn = $parent->getDeletedAtColumn(); 147 | return $parent->$deletedAtColumn === null; 148 | }); 149 | 150 | if (!count($parents)) { 151 | // If all parents are deleted, delete child 152 | $this->logger(get_class($childRecord) . " (ID: $childRecord->id) has no more live parents, deleting.."); 153 | $childRecord->delete(); 154 | } 155 | } 156 | 157 | /** 158 | * Handle state changes on child record if all parents share the same status 159 | * 160 | * @param Collection $parents A collection of parent models to check 161 | * @param Model $childRecord The related child record to modify 162 | * 163 | * @return void 164 | */ 165 | private function handleStateChange(Collection $parents, Model $childRecord): void 166 | { 167 | // Normalize 0/false/null 168 | $triggerValue = !!$this->triggerObj->{$this->syncProperty} ? 1 : 0; 169 | 170 | foreach ($parents as $parent) { 171 | $this->logger('Checking parent status for: ' . json_encode($parent)); 172 | 173 | // Normalize 0/false/null 174 | $parentValue = !!$parent->{$this->syncProperty} ? 1 : 0; 175 | 176 | // Short-circuit on the first non-homogenous match 177 | if ($parentValue !== $triggerValue) { 178 | $this->logger( 179 | "short circuit: parent $this->syncProperty value ($parentValue) ". 180 | " does not equal trigger object value ($triggerValue)" 181 | ); 182 | return; 183 | } 184 | } 185 | 186 | // If all parents share the same status, sync child 187 | $this->logger( 188 | "All parents share same $this->syncProperty value ($triggerValue), syncing " . 189 | get_class($this->triggerObj) . " (ID: $this->triggerObj->id).." 190 | ); 191 | 192 | $childRecord->update([$this->syncProperty => $triggerValue]); 193 | } 194 | 195 | /** 196 | * Reflect the model by attribute 197 | * 198 | * @param Model $model The Model object to retrieve children for 199 | */ 200 | private function getChildrenByReflection(Model $model): Collection 201 | { 202 | return $this->reflector($model, SyncTo::class); 203 | } 204 | 205 | /** 206 | * Reflect the model by attribute 207 | * 208 | * @param Model $model The Model object to retrieve parents for 209 | */ 210 | private function getParentsByReflection(Model $model): Collection 211 | { 212 | return $this->reflector($model, SyncFrom::class); 213 | } 214 | 215 | /** 216 | * Collect related models by method attribute 217 | * 218 | * @param Model $model The Model object to reflect 219 | * @param string $attributeClass The method attribute to filter by 220 | * 221 | * @return Collection A Collection of all related items 222 | */ 223 | private function reflector(Model $model, string $attributeClass): Collection 224 | { 225 | $relatedItems = []; 226 | 227 | collect( 228 | // Get the model's public methods 229 | (new \ReflectionClass($model)) 230 | ->getMethods(\ReflectionMethod::IS_PUBLIC) 231 | ) 232 | ->filter(function (\ReflectionMethod $method) use ($attributeClass) { 233 | // Filter the method list by the requested attribute 234 | return $method->getAttributes($attributeClass); 235 | }) 236 | ->each(function ($method) use ($model, &$relatedItems) { 237 | // Compile the list of items using the model method 238 | $methodName = $method->name; 239 | $relatedItems = [ 240 | ...$relatedItems, 241 | ...$model->$methodName()->get() 242 | ]; 243 | }); 244 | 245 | return collect($relatedItems); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/Providers/DeepSyncProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 14 | __DIR__.'/../config/deepsync.php' => config_path('deepsync.php'), 15 | ], 'deepsync'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/config/deepsync.php: -------------------------------------------------------------------------------- 1 | [ 10 | 'enabled' => true, 11 | 'log_level' => 'info' 12 | ] 13 | ]; --------------------------------------------------------------------------------