├── phpstan.neon ├── phpunit-10.xml ├── license.md ├── composer.json ├── src └── ServiceProvider.php └── README.md /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | # - tests 9 | 10 | # Level 9 is the highest level 11 | level: 9 12 | -------------------------------------------------------------------------------- /phpunit-10.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | src/ 14 | 15 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © `2018` `Alfa Adhitya & `2021` `korridor` 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "korridor/laravel-has-many-sync", 3 | "description": "Laravel has many sync", 4 | "keywords": ["laravel", "eloquent", "relations", "has-many", "sync"], 5 | "homepage": "https://github.com/korridor/laravel-has-many-sync", 6 | "type": "library", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "korridor", 11 | "email": "26689068+korridor@users.noreply.github.com" 12 | }, 13 | { 14 | "name": "Alfa Adhitya", 15 | "email": "alfa2159@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "illuminate/database": "^10|^11|^12", 21 | "illuminate/support": "^10|^11|^12" 22 | }, 23 | "require-dev": { 24 | "friendsofphp/php-cs-fixer": "^3", 25 | "larastan/larastan": "^2|^3.0", 26 | "orchestra/testbench": "^8|^9|^10", 27 | "phpunit/phpunit": "^10.0|^11.5", 28 | "squizlabs/php_codesniffer": "^3.5" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "Korridor\\LaravelHasManySync\\": "src" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "Korridor\\LaravelHasManySync\\Tests\\": "tests/" 38 | } 39 | }, 40 | "extra": { 41 | "laravel": { 42 | "providers": [ 43 | "Korridor\\LaravelHasManySync\\ServiceProvider" 44 | ] 45 | } 46 | }, 47 | "scripts": { 48 | "test": "@php vendor/bin/phpunit", 49 | "test-coverage": [ 50 | "@putenv XDEBUG_MODE=coverage", 51 | "@php vendor/bin/phpunit --coverage-html coverage" 52 | ], 53 | "fix": "@php ./vendor/bin/php-cs-fixer fix", 54 | "lint": "@php ./vendor/bin/phpcs --extensions=php", 55 | "analyse": [ 56 | "@php ./vendor/bin/phpstan analyse --memory-limit=2G" 57 | ] 58 | }, 59 | "config": { 60 | "sort-packages": true 61 | }, 62 | "minimum-stability": "dev", 63 | "prefer-stable": true 64 | } 65 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | [], 'deleted' => [], 'updated' => [], 26 | ]; 27 | 28 | /** @var HasMany $this */ 29 | 30 | // Get the primary key. 31 | $relatedKeyName = $this->getRelated()->getKeyName(); 32 | 33 | // Get the current key values. 34 | $currentIds = $this->newQuery()->pluck($relatedKeyName)->all(); 35 | 36 | // Cast the given key to an integer if it is numeric. 37 | $castKey = function ($value) { 38 | if (is_null($value)) { 39 | return null; 40 | } 41 | 42 | return is_numeric($value) ? (int) $value : (string) $value; 43 | }; 44 | 45 | // Cast the given keys to integers if they are numeric and string otherwise. 46 | $castKeys = function ($keys) use ($castKey): array { 47 | return (array) array_map(function ($key) use ($castKey) { 48 | return $castKey($key); 49 | }, $keys); 50 | }; 51 | 52 | // The new ids, without null values 53 | $dataIds = Arr::where($castKeys(Arr::pluck($data, $relatedKeyName)), function ($value) { 54 | return !is_null($value); 55 | }); 56 | 57 | $problemKeys = array_diff($dataIds, $currentIds); 58 | if ($throwOnIdNotInScope && count($problemKeys) > 0) { 59 | throw (new ModelNotFoundException())->setModel( 60 | get_class($this->getRelated()), 61 | $problemKeys 62 | ); 63 | } 64 | 65 | // Get any non-matching rows. 66 | $deletedKeys = array_diff($currentIds, $dataIds); 67 | 68 | if ($deleting && count($deletedKeys) > 0) { 69 | $this->getRelated()->destroy($deletedKeys); 70 | $changes['deleted'] = $deletedKeys; 71 | } 72 | 73 | // Separate the submitted data into "update" and "new" 74 | // We determine "newRows" as those whose $relatedKeyName (usually 'id') is null. 75 | $newRows = Arr::where($data, function (array $row) use ($relatedKeyName) { 76 | $id = Arr::get($row, $relatedKeyName); 77 | return $id === null; 78 | }); 79 | 80 | // We determine "updateRows" as those whose $relatedKeyName (usually 'id') is set, not null. 81 | $updateRows = Arr::where($data, function (array $row) use ($relatedKeyName, $problemKeys) { 82 | $id = Arr::get($row, $relatedKeyName); 83 | return $id !== null && !in_array($id, $problemKeys, true); 84 | }); 85 | 86 | if (count($newRows) > 0) { 87 | $newRecords = $this->createMany($newRows); 88 | $changes['created'] = $castKeys( 89 | $newRecords->pluck($relatedKeyName)->toArray() 90 | ); 91 | } 92 | 93 | foreach ($updateRows as $row) { 94 | $updateModel = $this->getRelated() 95 | ->newQuery() 96 | ->where($relatedKeyName, $castKey(Arr::get($row, $relatedKeyName))) 97 | ->firstOrFail(); 98 | 99 | $updateModel->update($row); 100 | } 101 | 102 | $changes['updated'] = $castKeys(Arr::pluck($updateRows, $relatedKeyName)); 103 | 104 | return $changes; 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel HasMany Sync 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/korridor/laravel-has-many-sync?style=flat-square)](https://packagist.org/packages/korridor/laravel-has-many-sync) 4 | [![License](https://img.shields.io/packagist/l/korridor/laravel-has-many-sync?style=flat-square)](license.md) 5 | [![GitHub Workflow Lint](https://img.shields.io/github/actions/workflow/status/korridor/laravel-has-many-sync/lint.yml?label=lint&style=flat-square)](https://github.com/korridor/laravel-has-many-sync/actions/workflows/lint.yml) 6 | [![GitHub Workflow Tests](https://img.shields.io/github/actions/workflow/status/korridor/laravel-has-many-sync/unittests.yml?label=tests&style=flat-square)](https://github.com/korridor/laravel-has-many-sync/actions/workflows/unittests.yml) 7 | [![Codecov](https://img.shields.io/codecov/c/github/korridor/laravel-has-many-sync?style=flat-square)](https://codecov.io/gh/korridor/laravel-has-many-sync) 8 | 9 | Allow sync method for Laravel Has Many Relationship. 10 | 11 | > [!NOTE] 12 | > Check out **solidtime - The modern Open Source Time-Tracker** at [solidtime.io](https://www.solidtime.io) 13 | 14 | ## Installation 15 | 16 | You can install the package via composer with following command: 17 | 18 | ```bash 19 | composer require korridor/laravel-has-many-sync 20 | ``` 21 | 22 | If you want to use this package with older Laravel/PHP version please install the 1.* version. 23 | 24 | ```bash 25 | composer require korridor/laravel-has-many-merged "^1" 26 | ``` 27 | 28 | **Warning: The 1.\* versions use a different namespace!** 29 | 30 | ### Requirements 31 | 32 | This package is tested for the following Laravel and PHP versions: 33 | 34 | - 10.* (PHP 8.1, 8.2, 8.3) 35 | - 11.* (PHP 8.2, 8.3, 8.4) 36 | - 12.* (PHP 8.2, 8.3, 8.4) 37 | 38 | ## Usage 39 | 40 | ### Setup HasMany Relation 41 | 42 | ```php 43 | class Customer extends Model 44 | { 45 | /** 46 | * @return HasMany 47 | */ 48 | public function contacts(): HasMany 49 | { 50 | return $this->hasMany(CustomerContact::class); 51 | } 52 | } 53 | ``` 54 | 55 | You can access the sync method like this: 56 | 57 | ```php 58 | $customer->contacts()->sync([ 59 | [ 60 | 'id' => 1, 61 | 'name' => 'Alfa', 62 | 'phone_number' => '123', 63 | ], 64 | [ 65 | 'id' => null, 66 | 'name' => 'Adhitya', 67 | 'phone_number' => '234, 68 | ] 69 | ]); 70 | ``` 71 | 72 | The sync method accepts an array of data to place on the intermediate table. Any data that are not in the given array will be removed from the intermediate table. So, after this operation is complete, only the data in the given array will exist in the intermediate table. 73 | 74 | #### Syncing without deleting 75 | 76 | If you do not want to delete existing data, you may pass false value to the second parameter in the sync method. 77 | 78 | ```php 79 | $customer->contacts()->sync([ 80 | [ 81 | 'id' => 1, 82 | 'name' => 'Alfa', 83 | 'phone_number' => '123', 84 | ], 85 | [ 86 | 'id' => null, 87 | 'name' => 'Adhitya', 88 | 'phone_number' => '234, 89 | ] 90 | ], false); 91 | ``` 92 | 93 | #### Behaviour for IDs that are not part of the hasMany relation 94 | 95 | If an ID in the related data does not exist or is not in the scope of the `hasMany` relation, the `sync` function will throw a `ModelNotFoundException`. 96 | It is possible to modify this behavior with the `$throwOnIdNotInScope` attribute. Per default, this is set to `true`. If set to false, the `sync` function will ignore the Ids instead of throwing an exception. 97 | 98 | ```php 99 | $customer->contacts()->sync([ 100 | [ 101 | 'id' => 7, // ID that belongs to a different customer than `$customer` 102 | 'name' => 'Peter', 103 | 'phone_number' => '321', 104 | ], 105 | [ 106 | 'id' => 1000, // ID that does not exist 107 | 'name' => 'Alfa', 108 | 'phone_number' => '123', 109 | ], 110 | [ 111 | 'id' => null, 112 | 'name' => 'Adhitya', 113 | 'phone_number' => '234, 114 | ] 115 | ], throwOnIdNotInScope: false); 116 | ``` 117 | 118 | #### Example usage in the controller. 119 | 120 | ```php 121 | class CustomersController extends Controller 122 | { 123 | /** 124 | * Update the specified resource in storage. 125 | * 126 | * @param CustomerRequest $request 127 | * @param Customer $customer 128 | * @return \Illuminate\Http\Response 129 | */ 130 | public function update(CustomerRequest $request, Customer $customer) 131 | { 132 | DB::transaction(function () use ($customer, $request) { 133 | $customer->update($request->all()); 134 | $customer->contacts()->sync($request->get('contacts', [])); 135 | }); 136 | 137 | return redirect()->route('customers.index'); 138 | } 139 | } 140 | ``` 141 | 142 | ## Contributing 143 | 144 | I am open for suggestions and contributions. Just create an issue or a pull request. 145 | 146 | ### Local docker environment 147 | 148 | The `docker` folder contains a local docker environment for development. 149 | The docker workspace has composer and xdebug installed. 150 | 151 | ```bash 152 | docker-compose run workspace bash 153 | ``` 154 | 155 | ### Testing 156 | 157 | The `composer test` command runs all tests with [phpunit](https://phpunit.de/). 158 | The `composer test-coverage` command runs all tests with phpunit and creates a coverage report into the `coverage` folder. 159 | 160 | ### Codeformatting/Linting 161 | 162 | The `composer fix` command formats the code with [php-cs-fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer). 163 | The `composer lint` command checks the code with [phpcs](https://github.com/squizlabs/PHP_CodeSniffer). 164 | 165 | ## Credits 166 | 167 | This package is a fork of [alfa6661/laravel-hasmany-sync](https://github.com/alfa6661/laravel-hasmany-sync). 168 | 169 | ## License 170 | 171 | This package is licensed under the MIT License (MIT). Please see [license file](license.md) for more information. 172 | 173 | --------------------------------------------------------------------------------