├── .github ├── FUNDING.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ └── config.yml └── workflows │ ├── php-cs-fixer.yml │ ├── update-changelog.yml │ ├── dependabot-auto-merge.yml │ └── run-tests.yml ├── .gitignore ├── .editorconfig ├── tests ├── DummyWithTimestamps.php ├── Dummy.php ├── DummyWithSoftDeletes.php ├── DummyWithGlobalScope.php ├── TestCase.php └── SortableTest.php ├── src ├── EloquentSortableServiceProvider.php ├── EloquentModelSortedEvent.php ├── Sortable.php └── SortableTrait.php ├── config └── eloquent-sortable.php ├── phpunit.xml ├── LICENSE.md ├── .php_cs.dist.php ├── composer.json ├── CHANGELOG.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: spatie 2 | custom: https://spatie.be/open-source/support-us 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .php_cs 3 | .php_cs.cache 4 | .phpunit.result.cache 5 | build 6 | composer.lock 7 | coverage 8 | docs 9 | phpunit.xml 10 | psalm.xml 11 | testbench.yaml 12 | vendor 13 | composer.phar 14 | tests/_output/* 15 | .php-cs-fixer.cache 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 4 6 | indent_style = space 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Feature Request 4 | url: https://github.com/spatie/eloquent-sortable/discussions/new?category=ideas 5 | about: Share ideas for new features 6 | - name: Ask a Question 7 | url: https://github.com/spatie/eloquent-sortable/discussions/new?category=q-a 8 | about: Ask the community for help 9 | -------------------------------------------------------------------------------- /tests/DummyWithTimestamps.php: -------------------------------------------------------------------------------- 1 | name('eloquent-sortable') 14 | ->hasConfigFile(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/DummyWithSoftDeletes.php: -------------------------------------------------------------------------------- 1 | model = $model; 14 | } 15 | 16 | public function isFor(Model|string $model): bool 17 | { 18 | if (is_string($model)) { 19 | return $model === $this->model; 20 | } 21 | 22 | return get_class($model) === $this->model; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config/eloquent-sortable.php: -------------------------------------------------------------------------------- 1 | 'order_column', 8 | 9 | /* 10 | * Define if the models should sort when creating. 11 | * When true, the package will automatically assign the highest order number to a new model 12 | */ 13 | 'sort_when_creating' => true, 14 | 15 | /* 16 | * Define if the timestamps should be ignored when sorting. 17 | * When true, updated_at will not be updated when using setNewOrder 18 | */ 19 | 'ignore_timestamps' => false, 20 | ]; 21 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v5 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Run PHP CS Fixer 16 | uses: docker://oskarstark/php-cs-fixer-ga 17 | with: 18 | args: --config=.php_cs.dist.php --allow-risky=yes 19 | 20 | - name: Commit changes 21 | uses: stefanzweifel/git-auto-commit-action@v6 22 | with: 23 | commit_message: Fix styling 24 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Update Changelog" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v5 14 | with: 15 | ref: main 16 | 17 | - name: Update Changelog 18 | uses: stefanzweifel/changelog-updater-action@v1 19 | with: 20 | latest-version: ${{ github.event.release.name }} 21 | release-notes: ${{ github.event.release.body }} 22 | 23 | - name: Commit updated CHANGELOG 24 | uses: stefanzweifel/git-auto-commit-action@v6 25 | with: 26 | branch: main 27 | commit_message: Update CHANGELOG 28 | file_pattern: CHANGELOG.md 29 | -------------------------------------------------------------------------------- /tests/DummyWithGlobalScope.php: -------------------------------------------------------------------------------- 1 | where('is_active', true); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | app/ 6 | 7 | 8 | 9 | 10 | ./tests/ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Sortable.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one', 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | 'single_trait_insert_per_statement' => true, 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/eloquent-sortable", 3 | "description": "Sortable behaviour for eloquent models", 4 | "homepage": "https://github.com/spatie/eloquent-sortable", 5 | "authors": [ 6 | { 7 | "name": "Freek Van der Herten", 8 | "email": "freek@spatie.be" 9 | } 10 | ], 11 | "keywords": [ 12 | "sort", 13 | "sortable", 14 | "eloquent", 15 | "model", 16 | "laravel", 17 | "behaviour" 18 | ], 19 | "license": "MIT", 20 | "require": { 21 | "php": "^8.1", 22 | "illuminate/database": "^9.31|^10.0|^11.0|^12.0", 23 | "illuminate/support": "^9.31|^10.0|^11.0|^12.0", 24 | "nesbot/carbon": "^2.63|^3.0", 25 | "spatie/laravel-package-tools": "^1.9" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^9.5|^10.0|^11.5.3", 29 | "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Spatie\\EloquentSortable\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Spatie\\EloquentSortable\\Test\\": "tests" 39 | } 40 | }, 41 | "minimum-stability": "dev", 42 | "prefer-stable": true, 43 | "scripts": { 44 | "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes", 45 | "test": "vendor/bin/phpunit" 46 | }, 47 | "extra": { 48 | "laravel": { 49 | "providers": [ 50 | "Spatie\\EloquentSortable\\EloquentSortableServiceProvider" 51 | ] 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v2.4.0 17 | with: 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" 19 | compat-lookup: true 20 | 21 | - name: Auto-merge Dependabot PRs for semver-minor updates 22 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} 23 | run: gh pr merge --auto --merge "$PR_URL" 24 | env: 25 | PR_URL: ${{github.event.pull_request.html_url}} 26 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 27 | 28 | - name: Auto-merge Dependabot PRs for semver-patch updates 29 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 30 | run: gh pr merge --auto --merge "$PR_URL" 31 | env: 32 | PR_URL: ${{github.event.pull_request.html_url}} 33 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 34 | 35 | - name: Auto-merge Dependabot PRs for Action major versions when compatibility is higher than 90% 36 | if: ${{steps.metadata.outputs.package-ecosystem == 'github_actions' && steps.metadata.outputs.update-type == 'version-update:semver-major' && steps.metadata.outputs.compatibility-score >= 90}} 37 | run: gh pr merge --auto --merge "$PR_URL" 38 | env: 39 | PR_URL: ${{github.event.pull_request.html_url}} 40 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 41 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | os: [ubuntu-latest] 15 | php: [8.3, 8.2, 8.1] 16 | laravel: ['9.*', '10.*', '11.*', '12.*'] 17 | dependency-version: [prefer-lowest, prefer-stable] 18 | include: 19 | - laravel: 11.* 20 | testbench: 9.* 21 | - laravel: 10.* 22 | testbench: 8.* 23 | - laravel: 9.* 24 | testbench: 7.* 25 | - laravel: 12.* 26 | testbench: 10.* 27 | exclude: 28 | - laravel: 11.* 29 | php: 8.1 30 | - laravel: 12.* 31 | php: 8.1 32 | 33 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v5 38 | 39 | - name: Setup PHP 40 | uses: shivammathur/setup-php@v2 41 | with: 42 | php-version: ${{ matrix.php }} 43 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 44 | coverage: none 45 | 46 | - name: Setup problem matchers 47 | run: | 48 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 49 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 50 | 51 | - name: Install dependencies 52 | run: | 53 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 54 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 55 | 56 | - name: Execute tests 57 | run: vendor/bin/phpunit 58 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 15 | } 16 | 17 | /** 18 | * @param \Illuminate\Foundation\Application $app 19 | * 20 | * @return array 21 | */ 22 | protected function getPackageProviders($app) 23 | { 24 | return [ 25 | 26 | ]; 27 | } 28 | 29 | /** 30 | * @param \Illuminate\Foundation\Application $app 31 | */ 32 | protected function getEnvironmentSetUp($app) 33 | { 34 | $app['config']->set('database.default', 'sqlite'); 35 | $app['config']->set('database.connections.sqlite', [ 36 | 'driver' => 'sqlite', 37 | 'database' => ':memory:', 38 | 'prefix' => '', 39 | ]); 40 | } 41 | 42 | protected function setUpDatabase() 43 | { 44 | $this->app['db']->connection()->getSchemaBuilder()->create('dummies', function (Blueprint $table) { 45 | $table->increments('id'); 46 | $table->string('name'); 47 | $table->string('custom_column_sort'); 48 | $table->integer('order_column'); 49 | }); 50 | 51 | collect(range(1, 20))->each(function (int $i) { 52 | Dummy::create([ 53 | 'name' => $i, 54 | 'custom_column_sort' => rand(), 55 | ]); 56 | }); 57 | } 58 | 59 | protected function setUpSoftDeletes() 60 | { 61 | $this->app['db']->connection()->getSchemaBuilder()->table('dummies', function (Blueprint $table) { 62 | $table->softDeletes(); 63 | }); 64 | } 65 | 66 | protected function setUpIsActiveFieldForGlobalScope() 67 | { 68 | $this->app['db']->connection()->getSchemaBuilder()->table('dummies', function (Blueprint $table) { 69 | $table->boolean('is_active')->default(false); 70 | }); 71 | } 72 | 73 | protected function setUpTimestamps() 74 | { 75 | $this->app['db']->connection()->getSchemaBuilder()->table('dummies', function (Blueprint $table) { 76 | $table->timestamps(); 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/SortableTrait.php: -------------------------------------------------------------------------------- 1 | shouldSortWhenCreating()) { 17 | $model->setHighestOrderNumber(); 18 | } 19 | }); 20 | } 21 | 22 | public function setHighestOrderNumber(): void 23 | { 24 | $orderColumnName = $this->determineOrderColumnName(); 25 | 26 | $this->$orderColumnName = $this->getHighestOrderNumber() + 1; 27 | } 28 | 29 | public function getHighestOrderNumber(): int 30 | { 31 | return (int)$this->buildSortQuery()->max($this->determineOrderColumnName()); 32 | } 33 | 34 | public function getLowestOrderNumber(): int 35 | { 36 | return (int)$this->buildSortQuery()->min($this->determineOrderColumnName()); 37 | } 38 | 39 | public function scopeOrdered(Builder $query, string $direction = 'asc') 40 | { 41 | return $query->orderBy($this->determineOrderColumnName(), $direction); 42 | } 43 | 44 | public static function setNewOrder( 45 | $ids, 46 | int $startOrder = 1, 47 | ?string $primaryKeyColumn = null, 48 | ?callable $modifyQuery = null 49 | ): void { 50 | if (! is_array($ids) && ! $ids instanceof ArrayAccess) { 51 | throw new InvalidArgumentException('You must pass an array or ArrayAccess object to setNewOrder'); 52 | } 53 | 54 | $model = new static(); 55 | 56 | $orderColumnName = $model->determineOrderColumnName(); 57 | 58 | if (is_null($primaryKeyColumn)) { 59 | $primaryKeyColumn = $model->getQualifiedKeyName(); 60 | } 61 | 62 | if (config('eloquent-sortable.ignore_timestamps', false)) { 63 | static::$ignoreTimestampsOn = array_values(array_merge(static::$ignoreTimestampsOn, [static::class])); 64 | } 65 | 66 | foreach ($ids as $id) { 67 | static::withoutGlobalScope(SoftDeletingScope::class) 68 | ->when(is_callable($modifyQuery), function ($query) use ($modifyQuery) { 69 | return $modifyQuery($query); 70 | }) 71 | ->where($primaryKeyColumn, $id) 72 | ->update([$orderColumnName => $startOrder++]); 73 | } 74 | 75 | Event::dispatch(new EloquentModelSortedEvent(static::class)); 76 | 77 | if (config('eloquent-sortable.ignore_timestamps', false)) { 78 | static::$ignoreTimestampsOn = array_values(array_diff(static::$ignoreTimestampsOn, [static::class])); 79 | } 80 | } 81 | 82 | public static function setNewOrderByCustomColumn(string $primaryKeyColumn, $ids, int $startOrder = 1) 83 | { 84 | self::setNewOrder($ids, $startOrder, $primaryKeyColumn); 85 | } 86 | 87 | public function determineOrderColumnName(): string 88 | { 89 | return $this->sortable['order_column_name'] ?? config('eloquent-sortable.order_column_name', 'order_column'); 90 | } 91 | 92 | /** 93 | * Determine if the order column should be set when saving a new model instance. 94 | */ 95 | public function shouldSortWhenCreating(): bool 96 | { 97 | return $this->sortable['sort_when_creating'] ?? config('eloquent-sortable.sort_when_creating', true); 98 | } 99 | 100 | public function moveAfter(Sortable $model): void 101 | { 102 | $orderColumnName = $this->determineOrderColumnName(); 103 | 104 | $this->buildSortQuery() 105 | ->ordered() 106 | ->where($orderColumnName, '>', $model->$orderColumnName) 107 | ->increment($orderColumnName); 108 | 109 | $this->$orderColumnName = $model->$orderColumnName + 1; 110 | 111 | $this->saveQuietly(); 112 | } 113 | 114 | public function moveBefore(Sortable $model): void 115 | { 116 | $orderColumnName = $this->determineOrderColumnName(); 117 | 118 | $this->buildSortQuery() 119 | ->ordered() 120 | ->where($orderColumnName, '>=', $model->$orderColumnName) 121 | ->increment($orderColumnName); 122 | 123 | $this->$orderColumnName = $model->$orderColumnName; 124 | 125 | $this->saveQuietly(); 126 | } 127 | 128 | public function moveOrderDown(): static 129 | { 130 | $orderColumnName = $this->determineOrderColumnName(); 131 | 132 | $swapWithModel = $this->buildSortQuery()->limit(1) 133 | ->ordered() 134 | ->where($orderColumnName, '>', $this->$orderColumnName) 135 | ->first(); 136 | 137 | if (! $swapWithModel) { 138 | return $this; 139 | } 140 | 141 | return $this->swapOrderWithModel($swapWithModel); 142 | } 143 | 144 | public function moveOrderUp(): static 145 | { 146 | $orderColumnName = $this->determineOrderColumnName(); 147 | 148 | $swapWithModel = $this->buildSortQuery()->limit(1) 149 | ->ordered('desc') 150 | ->where($orderColumnName, '<', $this->$orderColumnName) 151 | ->first(); 152 | 153 | if (! $swapWithModel) { 154 | return $this; 155 | } 156 | 157 | return $this->swapOrderWithModel($swapWithModel); 158 | } 159 | 160 | public function swapOrderWithModel(Sortable $otherModel): static 161 | { 162 | $orderColumnName = $this->determineOrderColumnName(); 163 | 164 | $oldOrderOfOtherModel = $otherModel->$orderColumnName; 165 | 166 | $otherModel->$orderColumnName = $this->$orderColumnName; 167 | $otherModel->save(); 168 | 169 | $this->$orderColumnName = $oldOrderOfOtherModel; 170 | $this->save(); 171 | 172 | return $this; 173 | } 174 | 175 | public static function swapOrder(Sortable $model, Sortable $otherModel): void 176 | { 177 | $model->swapOrderWithModel($otherModel); 178 | } 179 | 180 | public function moveToStart(): static 181 | { 182 | $firstModel = $this->buildSortQuery()->limit(1) 183 | ->ordered() 184 | ->first(); 185 | 186 | if ($firstModel->getKey() === $this->getKey()) { 187 | return $this; 188 | } 189 | 190 | $orderColumnName = $this->determineOrderColumnName(); 191 | 192 | $this->$orderColumnName = $firstModel->$orderColumnName; 193 | $this->save(); 194 | 195 | $this->buildSortQuery()->where($this->getQualifiedKeyName(), '!=', $this->getKey())->increment( 196 | $orderColumnName 197 | ); 198 | 199 | return $this; 200 | } 201 | 202 | public function moveToEnd(): static 203 | { 204 | $maxOrder = $this->getHighestOrderNumber(); 205 | 206 | $orderColumnName = $this->determineOrderColumnName(); 207 | 208 | if ($this->$orderColumnName === $maxOrder) { 209 | return $this; 210 | } 211 | 212 | $oldOrder = $this->$orderColumnName; 213 | 214 | $this->$orderColumnName = $maxOrder; 215 | $this->save(); 216 | 217 | $this->buildSortQuery()->where($this->getQualifiedKeyName(), '!=', $this->getKey()) 218 | ->where($orderColumnName, '>', $oldOrder) 219 | ->decrement($orderColumnName); 220 | 221 | return $this; 222 | } 223 | 224 | public function isLastInOrder(): bool 225 | { 226 | $orderColumnName = $this->determineOrderColumnName(); 227 | 228 | return (int)$this->$orderColumnName === $this->getHighestOrderNumber(); 229 | } 230 | 231 | public function isFirstInOrder(): bool 232 | { 233 | $orderColumnName = $this->determineOrderColumnName(); 234 | 235 | return (int)$this->$orderColumnName === $this->getLowestOrderNumber(); 236 | } 237 | 238 | public function buildSortQuery(): Builder 239 | { 240 | return static::query(); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `eloquent-sortable` will be documented in this file 4 | 5 | ## 4.5.2 - 2025-08-25 6 | 7 | ### What's Changed 8 | 9 | * Revert "Check for parent::buildSortQuery() (#188)" by @phh in https://github.com/spatie/eloquent-sortable/pull/193 10 | 11 | ### New Contributors 12 | 13 | * @phh made their first contribution in https://github.com/spatie/eloquent-sortable/pull/193 14 | 15 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.5.1...4.5.2 16 | 17 | ## 4.5.1 - 2025-08-25 18 | 19 | ### What's Changed 20 | 21 | * Check for parent::buildSortQuery() by @JeroenHauser in https://github.com/spatie/eloquent-sortable/pull/188 22 | * Bump actions/checkout from 4 to 5 by @dependabot[bot] in https://github.com/spatie/eloquent-sortable/pull/191 23 | * Bump stefanzweifel/git-auto-commit-action from 5 to 6 by @dependabot[bot] in https://github.com/spatie/eloquent-sortable/pull/190 24 | 25 | ### New Contributors 26 | 27 | * @JeroenHauser made their first contribution in https://github.com/spatie/eloquent-sortable/pull/188 28 | 29 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.5.0...4.5.1 30 | 31 | ## 4.5.0 - 2025-06-03 32 | 33 | ### What's Changed 34 | 35 | * Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot in https://github.com/spatie/eloquent-sortable/pull/187 36 | * Add `moveAfter` and `moveBefore` methods for precise positioning by @SebastiaanKloos in https://github.com/spatie/eloquent-sortable/pull/189 37 | 38 | ### New Contributors 39 | 40 | * @SebastiaanKloos made their first contribution in https://github.com/spatie/eloquent-sortable/pull/189 41 | 42 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.4.2...4.5.0 43 | 44 | ## 4.4.2 - 2025-02-19 45 | 46 | ### What's Changed 47 | 48 | * Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/spatie/eloquent-sortable/pull/185 49 | * Laravel 12.x Compatibility by @laravel-shift in https://github.com/spatie/eloquent-sortable/pull/186 50 | 51 | ### New Contributors 52 | 53 | * @laravel-shift made their first contribution in https://github.com/spatie/eloquent-sortable/pull/186 54 | 55 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.4.1...4.4.2 56 | 57 | ## 4.4.1 - 2024-12-23 58 | 59 | ### What's Changed 60 | 61 | * Bump dependabot/fetch-metadata from 1.6.0 to 2.1.0 by @dependabot in https://github.com/spatie/eloquent-sortable/pull/176 62 | * Bump dependabot/fetch-metadata from 2.1.0 to 2.2.0 by @dependabot in https://github.com/spatie/eloquent-sortable/pull/181 63 | * Fix PHP 8.4 deprecation notice by @clarkewing in https://github.com/spatie/eloquent-sortable/pull/184 64 | 65 | ### New Contributors 66 | 67 | * @clarkewing made their first contribution in https://github.com/spatie/eloquent-sortable/pull/184 68 | 69 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.4.0...4.4.1 70 | 71 | ## 4.4.0 - 2024-06-04 72 | 73 | ### What's Changed 74 | 75 | * Dispatch event after performing a sort by @chrispage1 in https://github.com/spatie/eloquent-sortable/pull/178 76 | 77 | ### New Contributors 78 | 79 | * @chrispage1 made their first contribution in https://github.com/spatie/eloquent-sortable/pull/178 80 | 81 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.3.0...4.4.0 82 | 83 | ## 4.3.0 - 2024-05-02 84 | 85 | ### What's Changed 86 | 87 | * Use model's qualified key name for update queries by @JeremyDunn in https://github.com/spatie/eloquent-sortable/pull/175 88 | 89 | ### New Contributors 90 | 91 | * @JeremyDunn made their first contribution in https://github.com/spatie/eloquent-sortable/pull/175 92 | 93 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.2.0...4.3.0 94 | 95 | ## 4.2.0 - 2024-02-26 96 | 97 | ### What's Changed 98 | 99 | * Bump stefanzweifel/git-auto-commit-action from 4 to 5 by @dependabot in https://github.com/spatie/eloquent-sortable/pull/162 100 | * Support laravel 11 by @mokhosh in https://github.com/spatie/eloquent-sortable/pull/171 101 | * bump min laravel 9 version to support ignore timestamps by @mokhosh in https://github.com/spatie/eloquent-sortable/pull/173 102 | 103 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.1.1...4.2.0 104 | 105 | ## 4.1.1 - 2024-02-06 106 | 107 | ### What's Changed 108 | 109 | * Fix typo in eloquent-sortable.php by @dissto in https://github.com/spatie/eloquent-sortable/pull/168 110 | * Add ignore timestamp by @mokhosh in https://github.com/spatie/eloquent-sortable/pull/169 111 | 112 | ### New Contributors 113 | 114 | * @dissto made their first contribution in https://github.com/spatie/eloquent-sortable/pull/168 115 | * @mokhosh made their first contribution in https://github.com/spatie/eloquent-sortable/pull/169 116 | 117 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.1.0...4.1.1 118 | 119 | ## 4.0.2 - 2023-01-23 120 | 121 | ### What's Changed 122 | 123 | - Update readme about publishing config file by @patrickbrouwers in https://github.com/spatie/eloquent-sortable/pull/120 124 | - Add Dependabot Automation by @patinthehat in https://github.com/spatie/eloquent-sortable/pull/136 125 | - Bump actions/checkout from 2 to 3 by @dependabot in https://github.com/spatie/eloquent-sortable/pull/137 126 | - Add PHP 8.2 Support by @patinthehat in https://github.com/spatie/eloquent-sortable/pull/138 127 | - Laravel 10 support by @Okipa in https://github.com/spatie/eloquent-sortable/pull/142 128 | 129 | ### New Contributors 130 | 131 | - @patrickbrouwers made their first contribution in https://github.com/spatie/eloquent-sortable/pull/120 132 | - @dependabot made their first contribution in https://github.com/spatie/eloquent-sortable/pull/137 133 | - @Okipa made their first contribution in https://github.com/spatie/eloquent-sortable/pull/142 134 | 135 | **Full Changelog**: https://github.com/spatie/eloquent-sortable/compare/4.0.1...4.0.2 136 | 137 | ## 4.1.0 - 2023-01-24 138 | 139 | - add support for Laravel 10 140 | - drop support for Laravel 8 141 | - drop support for PHP 8.0 142 | 143 | ## 4.0.1 - 2022-01-21 144 | 145 | - support Laravel 9 146 | 147 | ## 4.0.0 - 2021-03-21 148 | 149 | - require PHP 8+ 150 | - drop support for all PHP 7.x versions 151 | - use PHP 8 syntax 152 | 153 | ## 3.11.0 - 2021-01-18 154 | 155 | - add methods to determine whether element is the last or first in order (#102) 156 | 157 | ## 3.10.0 - 2020-11-25 158 | 159 | - add support for PHP 8.0 160 | - drop support for Laravel 5.8 161 | 162 | ## 3.9.0 - 2020-09-16 163 | 164 | - add config file 165 | 166 | ## 3.8.3 - 2020-09-08 167 | 168 | - add support for Laravel 8 169 | 170 | ## 3.8.2 - 2020-07-08 171 | 172 | - reduce dependency tree (#89) 173 | 174 | ## 3.8.1 - 2020-06-26 175 | 176 | - models don't always have an id as key, use `getKey` instead 177 | 178 | ## 3.8.0 - 2020-03-02 179 | 180 | - add support for Laravel 7 181 | 182 | ## 3.7.0 - 2019-09-04 183 | 184 | - add support for Laravel 6 185 | 186 | ## 3.6.0 - 2019-04-01 187 | 188 | - allow `setNewOrder` to accept a custom sort column 189 | 190 | ## 3.5.0 - 2019-02-27 191 | 192 | - drop support for L5.7 and below, PHP 7.1 and PHPUnit 7 193 | 194 | ## 3.4.4 - 2019-02-27 195 | 196 | - add support for Laravel 5.8 197 | 198 | ## 3.4.3 - 2018-08-24 199 | 200 | - add support for Laravel 5.7 201 | 202 | ## 3.4.2 - 2018-02-08 203 | 204 | - add support for L5.6 205 | - drop support for anything lower that L5.5 206 | 207 | ## 3.4.1 208 | 209 | - fix deps 210 | 211 | ## 3.4.0 212 | 213 | - add compatibility with Laravel 5.5 214 | 215 | ## 3.3.0 - 2017-04-16 216 | 217 | - add `buildSortQuery()` 218 | 219 | ## 3.2.1 - 2017-01-23 220 | 221 | - release without changes. Made to kickstart Packagist. 222 | 223 | ## 3.2.0 - 2017-01-23 224 | 225 | - add compatibility with Laravel 5.4 226 | 227 | ## 3.1.0 - 2016-11-20 228 | 229 | - added support for `SoftDeletes` 230 | 231 | ## 3.0.0 - 2016-10-22 232 | 233 | - removed the need for a service provider 234 | - some cleanup 235 | 236 | ## 2.3.0 - 2016-10-19 237 | 238 | - added support for collections passed to `setNewOrder` 239 | 240 | ## 2.2.0 - 2016-10-19 241 | 242 | - added `moveToStart`, `moveToEnd` and `swapOrder` 243 | 244 | ## 2.1.1 - 2016-03-21 245 | 246 | - Fixed a bug in `moveOrderUp` (see #13) 247 | 248 | ## 2.1.0 249 | 250 | - Added `moveOrderUp`- and `moveOrderDown`-methods 251 | 252 | ## 2.0.1 253 | 254 | - Fixed typehinting on scope 255 | 256 | ## 2.0.0 257 | 258 | - SortableInterface is now Sortable 259 | - Sortable is now SortableTrait 260 | - getHighestOrderNumber() now retrieves the highest existing order number (not a new one) 261 | - setHighestOrderNumber() no longer requires a Sortable object parameter 262 | - sort_when_creating option 263 | - Added shouldSortWhenCreating function 264 | - Added test coverage 265 | 266 | ## 1.1.2 267 | 268 | - Removed typehinting on scope in interface. 269 | 270 | ## 1.1.1 (non-functional version!) 271 | 272 | - Removed typehinting on scope 273 | 274 | ## 1.1.0 275 | 276 | - Added an argument to `setNewOrder` to specify the starting order 277 | - Adopted psr-2 and psr-4 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for eloquent-sortable 6 | 7 | 8 | 9 |

Sortable behaviour for Eloquent models

10 | 11 | [![Latest Version](https://img.shields.io/github/release/spatie/eloquent-sortable.svg?style=flat-square)](https://github.com/spatie/eloquent-sortable/releases) 12 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/spatie/eloquent-sortable/run-tests.yml?branch=main&label=tests) 13 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/eloquent-sortable.svg?style=flat-square)](https://packagist.org/packages/spatie/eloquent-sortable) 15 | 16 |
17 | 18 | This package provides a trait that adds sortable behaviour to an Eloquent model. 19 | 20 | The value of the order column of a new record of a model is determined by the maximum value of the order column of all records of that model + 1. 21 | 22 | The package also provides a query scope to fetch all the records in the right order. 23 | 24 | Spatie is a webdesign agency in Antwerp, Belgium. You'll find an overview of all our open source projects [on our website](https://spatie.be/opensource). 25 | 26 | ## Support us 27 | 28 | Learn how to create a package like this one, by watching our premium video course: 29 | 30 | [![Laravel Package training](https://spatie.be/github/package-training.jpg)](https://laravelpackage.training) 31 | 32 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 33 | 34 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 35 | 36 | ## Installation 37 | 38 | > For Laravel 6.x or PHP 7.x, use version 3.x of this package. 39 | 40 | This package can be installed through Composer. 41 | 42 | ``` 43 | composer require spatie/eloquent-sortable 44 | ``` 45 | 46 | In Laravel 5.5 and above the service provider will automatically get registered. In older versions of the framework just add the service provider in `config/app.php` file: 47 | 48 | ```php 49 | 'providers' => [ 50 | ... 51 | Spatie\EloquentSortable\EloquentSortableServiceProvider::class, 52 | ]; 53 | ``` 54 | 55 | Optionally you can publish the config file with: 56 | 57 | ```bash 58 | php artisan vendor:publish --tag=eloquent-sortable-config 59 | ``` 60 | 61 | This is the content of the file that will be published in `config/eloquent-sortable.php` 62 | 63 | ```php 64 | return [ 65 | /* 66 | * The name of the column that will be used to sort models. 67 | */ 68 | 'order_column_name' => 'order_column', 69 | 70 | /* 71 | * Define if the models should sort when creating. When true, the package 72 | * will automatically assign the highest order number to a new model 73 | */ 74 | 'sort_when_creating' => true, 75 | 76 | /* 77 | * Define if the timestamps should be ignored when sorting. 78 | * When true, updated_at will not be updated when using setNewOrder 79 | */ 80 | 'ignore_timestamps' => false, 81 | ]; 82 | ``` 83 | 84 | ## Usage 85 | 86 | To add sortable behaviour to your model you must: 87 | 1. Implement the `Spatie\EloquentSortable\Sortable` interface. 88 | 2. Use the trait `Spatie\EloquentSortable\SortableTrait`. 89 | 3. Optionally specify which column will be used as the order column. The default is `order_column`. 90 | 91 | ### Example 92 | 93 | ```php 94 | use Spatie\EloquentSortable\Sortable; 95 | use Spatie\EloquentSortable\SortableTrait; 96 | 97 | class MyModel extends Model implements Sortable 98 | { 99 | use SortableTrait; 100 | 101 | public $sortable = [ 102 | 'order_column_name' => 'order_column', 103 | 'sort_when_creating' => true, 104 | ]; 105 | 106 | // ... 107 | } 108 | ``` 109 | 110 | If you don't set a value `$sortable['order_column_name']` the package will assume that your order column name will be named `order_column`. 111 | 112 | If you don't set a value `$sortable['sort_when_creating']` the package will automatically assign the highest order number to a new model; 113 | 114 | Assuming that the db-table for `MyModel` is empty: 115 | 116 | ```php 117 | $myModel = new MyModel(); 118 | $myModel->save(); // order_column for this record will be set to 1 119 | 120 | $myModel = new MyModel(); 121 | $myModel->save(); // order_column for this record will be set to 2 122 | 123 | $myModel = new MyModel(); 124 | $myModel->save(); // order_column for this record will be set to 3 125 | 126 | 127 | //the trait also provides the ordered query scope 128 | $orderedRecords = MyModel::ordered()->get(); 129 | ``` 130 | 131 | You can set a new order for all the records using the `setNewOrder`-method 132 | 133 | ```php 134 | /** 135 | * the record for model id 3 will have order_column value 1 136 | * the record for model id 1 will have order_column value 2 137 | * the record for model id 2 will have order_column value 3 138 | */ 139 | MyModel::setNewOrder([3,1,2]); 140 | ``` 141 | 142 | Optionally you can pass the starting order number as the second argument. 143 | 144 | ```php 145 | /** 146 | * the record for model id 3 will have order_column value 11 147 | * the record for model id 1 will have order_column value 12 148 | * the record for model id 2 will have order_column value 13 149 | */ 150 | MyModel::setNewOrder([3,1,2], 10); 151 | ``` 152 | 153 | You can modify the query that will be executed by passing a closure as the fourth argument. 154 | 155 | ```php 156 | /** 157 | * the record for model id 3 will have order_column value 11 158 | * the record for model id 1 will have order_column value 12 159 | * the record for model id 2 will have order_column value 13 160 | */ 161 | MyModel::setNewOrder([3,1,2], 10, null, function($query) { 162 | $query->withoutGlobalScope(new ActiveScope); 163 | }); 164 | ``` 165 | 166 | 167 | To sort using a column other than the primary key, use the `setNewOrderByCustomColumn`-method. 168 | 169 | ```php 170 | /** 171 | * the record for model uuid '7a051131-d387-4276-bfda-e7c376099715' will have order_column value 1 172 | * the record for model uuid '40324562-c7ca-4c69-8018-aff81bff8c95' will have order_column value 2 173 | * the record for model uuid '5dc4d0f4-0c88-43a4-b293-7c7902a3cfd1' will have order_column value 3 174 | */ 175 | MyModel::setNewOrderByCustomColumn('uuid', [ 176 | '7a051131-d387-4276-bfda-e7c376099715', 177 | '40324562-c7ca-4c69-8018-aff81bff8c95', 178 | '5dc4d0f4-0c88-43a4-b293-7c7902a3cfd1' 179 | ]); 180 | ``` 181 | 182 | As with `setNewOrder`, `setNewOrderByCustomColumn` will also accept an optional starting order argument. 183 | 184 | ```php 185 | /** 186 | * the record for model uuid '7a051131-d387-4276-bfda-e7c376099715' will have order_column value 10 187 | * the record for model uuid '40324562-c7ca-4c69-8018-aff81bff8c95' will have order_column value 11 188 | * the record for model uuid '5dc4d0f4-0c88-43a4-b293-7c7902a3cfd1' will have order_column value 12 189 | */ 190 | MyModel::setNewOrderByCustomColumn('uuid', [ 191 | '7a051131-d387-4276-bfda-e7c376099715', 192 | '40324562-c7ca-4c69-8018-aff81bff8c95', 193 | '5dc4d0f4-0c88-43a4-b293-7c7902a3cfd1' 194 | ], 10); 195 | ``` 196 | 197 | You can also move a model up or down with these methods: 198 | 199 | ```php 200 | $myModel->moveOrderDown(); 201 | $myModel->moveOrderUp(); 202 | ``` 203 | 204 | You can also move a model to the first or last position: 205 | 206 | ```php 207 | $myModel->moveToStart(); 208 | $myModel->moveToEnd(); 209 | ``` 210 | 211 | You can determine whether an element is first or last in order: 212 | 213 | ```php 214 | $myModel->isFirstInOrder(); 215 | $myModel->isLastInOrder(); 216 | ``` 217 | 218 | You can swap the order of two models: 219 | 220 | ```php 221 | MyModel::swapOrder($myModel, $anotherModel); 222 | ``` 223 | 224 | ### Grouping 225 | 226 | If your model/table has a grouping field (usually a foreign key): `id, `**`user_id`**`, title, order_column` 227 | and you'd like the above methods to take it into considerations, you can create a `buildSortQuery` method at your model: 228 | ```php 229 | // MyModel.php 230 | 231 | public function buildSortQuery() 232 | { 233 | return static::query()->where('user_id', $this->user_id); 234 | } 235 | ``` 236 | This will restrict the calculations to fields value of the model instance. 237 | 238 | ### Dispatched events 239 | 240 | Once a sort has been completed, an event (`Spatie\EloquentSortable\EloquentModelSortedEvent`) is dispatched that you 241 | can listen for. This can be useful for running post-sorting logic such as clearing caches or other actions that 242 | need to be taken after a sort. 243 | 244 | The event has an `isFor` helper which allows you to conveniently check the Eloquent class that has been sorted. 245 | 246 | Below is an example of how you can listen for this event: 247 | 248 | ```php 249 | use Spatie\EloquentSortable\EloquentModelSortedEvent as SortEvent; 250 | 251 | class SortingListener 252 | { 253 | public function handle(SortEvent $event): void { 254 | if ($event->isFor(MyClass::class)) { 255 | // ToDo: flush our cache 256 | } 257 | } 258 | } 259 | ``` 260 | 261 | ## Tests 262 | 263 | The package contains some integration/smoke tests, set up with Orchestra. The tests can be run via phpunit. 264 | 265 | ```bash 266 | vendor/bin/phpunit 267 | ``` 268 | 269 | ## Changelog 270 | 271 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 272 | 273 | ## Contributing 274 | 275 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 276 | 277 | ## Security Vulnerabilities 278 | 279 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 280 | 281 | ## Credits 282 | 283 | - [Freek Van der Herten](https://github.com/freekmurze) 284 | - [All Contributors](../../contributors) 285 | 286 | ## Alternatives 287 | - [Listify](https://github.com/lookitsatravis/listify) 288 | - [Rutorike-sortable](https://github.com/boxfrommars/rutorika-sortable) 289 | 290 | ## License 291 | 292 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 293 | -------------------------------------------------------------------------------- /tests/SortableTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($dummy->name, $dummy->order_column); 16 | } 17 | } 18 | 19 | /** @test */ 20 | public function it_can_get_the_highest_order_number() 21 | { 22 | $this->assertEquals(Dummy::all()->count(), (new Dummy())->getHighestOrderNumber()); 23 | } 24 | 25 | /** @test */ 26 | public function it_can_get_the_highest_order_number_with_trashed_models() 27 | { 28 | $this->setUpSoftDeletes(); 29 | 30 | DummyWithSoftDeletes::first()->delete(); 31 | 32 | $this->assertEquals(DummyWithSoftDeletes::withTrashed()->count(), (new DummyWithSoftDeletes())->getHighestOrderNumber()); 33 | } 34 | 35 | /** @test */ 36 | public function it_can_set_a_new_order() 37 | { 38 | 39 | Event::fake(EloquentModelSortedEvent::class); 40 | 41 | $newOrder = Collection::make(Dummy::all()->pluck('id'))->shuffle()->toArray(); 42 | 43 | Dummy::setNewOrder($newOrder); 44 | 45 | foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { 46 | $this->assertEquals($newOrder[$i], $dummy->id); 47 | } 48 | 49 | Event::assertDispatched(EloquentModelSortedEvent::class, function (EloquentModelSortedEvent $event) { 50 | return $event->isFor(Dummy::class); 51 | }); 52 | } 53 | 54 | /** @test */ 55 | public function it_can_touch_timestamps_when_setting_a_new_order() 56 | { 57 | $this->setUpTimestamps(); 58 | DummyWithTimestamps::query()->update(['updated_at' => now()]); 59 | $originalTimestamps = DummyWithTimestamps::all()->pluck('updated_at'); 60 | 61 | $this->travelTo(now()->addMinute()); 62 | 63 | config()->set('eloquent-sortable.ignore_timestamps', false); 64 | $newOrder = Collection::make(DummyWithTimestamps::all()->pluck('id'))->shuffle()->toArray(); 65 | DummyWithTimestamps::setNewOrder($newOrder); 66 | 67 | foreach (DummyWithTimestamps::orderBy('order_column')->get() as $i => $dummy) { 68 | $this->assertNotEquals($originalTimestamps[$i], $dummy->updated_at); 69 | } 70 | } 71 | 72 | /** @test */ 73 | public function it_can_set_a_new_order_without_touching_timestamps() 74 | { 75 | $this->setUpTimestamps(); 76 | DummyWithTimestamps::query()->update(['updated_at' => now()]); 77 | $originalTimestamps = DummyWithTimestamps::all()->pluck('updated_at'); 78 | 79 | $this->travelTo(now()->addMinute()); 80 | 81 | config()->set('eloquent-sortable.ignore_timestamps', true); 82 | $newOrder = Collection::make(DummyWithTimestamps::all()->pluck('id'))->shuffle()->toArray(); 83 | DummyWithTimestamps::setNewOrder($newOrder); 84 | 85 | foreach (DummyWithTimestamps::orderBy('order_column')->get() as $i => $dummy) { 86 | $this->assertEquals($originalTimestamps[$i], $dummy->updated_at); 87 | } 88 | } 89 | 90 | /** @test */ 91 | public function it_can_set_a_new_order_by_custom_column() 92 | { 93 | $newOrder = Collection::make(Dummy::all()->pluck('custom_column_sort'))->shuffle()->toArray(); 94 | 95 | Dummy::setNewOrderByCustomColumn('custom_column_sort', $newOrder); 96 | 97 | foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { 98 | $this->assertEquals($newOrder[$i], $dummy->custom_column_sort); 99 | } 100 | } 101 | 102 | /** @test */ 103 | public function it_can_set_a_new_order_from_collection() 104 | { 105 | $newOrder = Collection::make(Dummy::all()->pluck('id'))->shuffle(); 106 | 107 | Dummy::setNewOrder($newOrder); 108 | 109 | foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { 110 | $this->assertEquals($newOrder[$i], $dummy->id); 111 | } 112 | } 113 | 114 | /** @test */ 115 | public function it_can_set_a_new_order_by_custom_column_from_collection() 116 | { 117 | $newOrder = Collection::make(Dummy::all()->pluck('custom_column_sort'))->shuffle(); 118 | 119 | Dummy::setNewOrderByCustomColumn('custom_column_sort', $newOrder); 120 | 121 | foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { 122 | $this->assertEquals($newOrder[$i], $dummy->custom_column_sort); 123 | } 124 | } 125 | 126 | /** @test */ 127 | public function it_can_set_new_order_without_global_scopes_models() 128 | { 129 | $this->setUpIsActiveFieldForGlobalScope(); 130 | 131 | $newOrder = Collection::make(Dummy::all()->pluck('id'))->shuffle()->toArray(); 132 | 133 | DummyWithGlobalScope::setNewOrder($newOrder, 1, null, function ($query) { 134 | $query->withoutGlobalScope('ActiveScope'); 135 | }); 136 | 137 | foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { 138 | $this->assertEquals($newOrder[$i], $dummy->id); 139 | } 140 | } 141 | 142 | /** @test */ 143 | public function it_can_set_a_new_order_with_trashed_models() 144 | { 145 | $this->setUpSoftDeletes(); 146 | 147 | $dummies = DummyWithSoftDeletes::all(); 148 | 149 | $dummies->random()->delete(); 150 | 151 | $newOrder = Collection::make($dummies->pluck('id'))->shuffle(); 152 | 153 | DummyWithSoftDeletes::setNewOrder($newOrder); 154 | 155 | foreach (DummyWithSoftDeletes::withTrashed()->orderBy('order_column')->get() as $i => $dummy) { 156 | $this->assertEquals($newOrder[$i], $dummy->id); 157 | } 158 | } 159 | 160 | /** @test */ 161 | public function it_can_set_a_new_order_by_custom_column_with_trashed_models() 162 | { 163 | $this->setUpSoftDeletes(); 164 | 165 | $dummies = DummyWithSoftDeletes::all(); 166 | 167 | $dummies->random()->delete(); 168 | 169 | $newOrder = Collection::make($dummies->pluck('custom_column_sort'))->shuffle(); 170 | 171 | DummyWithSoftDeletes::setNewOrderByCustomColumn('custom_column_sort', $newOrder); 172 | 173 | foreach (DummyWithSoftDeletes::withTrashed()->orderBy('order_column')->get() as $i => $dummy) { 174 | $this->assertEquals($newOrder[$i], $dummy->custom_column_sort); 175 | } 176 | } 177 | 178 | /** @test */ 179 | public function it_can_set_a_new_order_without_trashed_models() 180 | { 181 | $this->setUpSoftDeletes(); 182 | 183 | DummyWithSoftDeletes::first()->delete(); 184 | 185 | $newOrder = Collection::make(DummyWithSoftDeletes::pluck('id'))->shuffle(); 186 | 187 | DummyWithSoftDeletes::setNewOrder($newOrder); 188 | 189 | foreach (DummyWithSoftDeletes::orderBy('order_column')->get() as $i => $dummy) { 190 | $this->assertEquals($newOrder[$i], $dummy->id); 191 | } 192 | } 193 | 194 | /** @test */ 195 | public function it_can_set_a_new_order_by_custom_column_without_trashed_models() 196 | { 197 | $this->setUpSoftDeletes(); 198 | 199 | DummyWithSoftDeletes::first()->delete(); 200 | 201 | $newOrder = Collection::make(DummyWithSoftDeletes::pluck('custom_column_sort'))->shuffle(); 202 | 203 | DummyWithSoftDeletes::setNewOrderByCustomColumn('custom_column_sort', $newOrder); 204 | 205 | foreach (DummyWithSoftDeletes::orderBy('order_column')->get() as $i => $dummy) { 206 | $this->assertEquals($newOrder[$i], $dummy->custom_column_sort); 207 | } 208 | } 209 | 210 | /** @test */ 211 | public function it_will_determine_to_sort_when_creating_if_sortable_attribute_does_not_exist() 212 | { 213 | $model = new Dummy(); 214 | 215 | $this->assertTrue($model->shouldSortWhenCreating()); 216 | } 217 | 218 | /** @test */ 219 | public function it_will_determine_to_sort_when_creating_if_sort_when_creating_setting_does_not_exist() 220 | { 221 | $model = new class () extends Dummy { 222 | public $sortable = []; 223 | }; 224 | 225 | $this->assertTrue($model->shouldSortWhenCreating()); 226 | } 227 | 228 | /** @test */ 229 | public function it_will_respect_the_sort_when_creating_setting() 230 | { 231 | $model = new class () extends Dummy { 232 | public $sortable = ['sort_when_creating' => true]; 233 | }; 234 | 235 | $this->assertTrue($model->shouldSortWhenCreating()); 236 | 237 | $model = new class () extends Dummy { 238 | public $sortable = ['sort_when_creating' => false]; 239 | }; 240 | $this->assertFalse($model->shouldSortWhenCreating()); 241 | } 242 | 243 | /** @test */ 244 | public function it_provides_an_ordered_trait() 245 | { 246 | $i = 1; 247 | 248 | foreach (Dummy::ordered()->get()->pluck('order_column') as $order) { 249 | $this->assertEquals($i++, $order); 250 | } 251 | } 252 | 253 | /** @test */ 254 | public function it_can_move_after() 255 | { 256 | $firstModel = Dummy::find(3); 257 | $secondModel = Dummy::find(4); 258 | $newModel = Dummy::create(['name' => 'New dummy', 'custom_column_sort' => rand()]); 259 | 260 | $newModel->moveAfter($firstModel); 261 | 262 | $newModel->refresh(); 263 | $secondModel->refresh(); 264 | 265 | $this->assertEquals($newModel->order_column, 4); 266 | $this->assertEquals($secondModel->order_column, 5); 267 | } 268 | 269 | /** @test */ 270 | public function it_can_move_before() 271 | { 272 | $firstModel = Dummy::find(4); 273 | $newModel = Dummy::create(['name' => 'New dummy', 'custom_column_sort' => rand()]); 274 | 275 | $newModel->moveBefore($firstModel); 276 | 277 | $firstModel->refresh(); 278 | $newModel->refresh(); 279 | 280 | $this->assertEquals($newModel->order_column, 4); 281 | $this->assertEquals($firstModel->order_column, 5); 282 | } 283 | 284 | /** @test */ 285 | public function it_can_move_the_order_down() 286 | { 287 | $firstModel = Dummy::find(3); 288 | $secondModel = Dummy::find(4); 289 | 290 | $this->assertEquals($firstModel->order_column, 3); 291 | $this->assertEquals($secondModel->order_column, 4); 292 | 293 | $this->assertNotFalse($firstModel->moveOrderDown()); 294 | 295 | $firstModel = Dummy::find(3); 296 | $secondModel = Dummy::find(4); 297 | 298 | $this->assertEquals($firstModel->order_column, 4); 299 | $this->assertEquals($secondModel->order_column, 3); 300 | } 301 | 302 | /** @test */ 303 | public function it_will_not_fail_when_it_cant_move_the_order_down() 304 | { 305 | $lastModel = Dummy::all()->last(); 306 | 307 | $this->assertEquals($lastModel->order_column, 20); 308 | $this->assertEquals($lastModel, $lastModel->moveOrderDown()); 309 | } 310 | 311 | /** @test */ 312 | public function it_can_move_the_order_up() 313 | { 314 | $firstModel = Dummy::find(3); 315 | $secondModel = Dummy::find(4); 316 | 317 | $this->assertEquals($firstModel->order_column, 3); 318 | $this->assertEquals($secondModel->order_column, 4); 319 | 320 | $this->assertNotFalse($secondModel->moveOrderUp()); 321 | 322 | $firstModel = Dummy::find(3); 323 | $secondModel = Dummy::find(4); 324 | 325 | $this->assertEquals($firstModel->order_column, 4); 326 | $this->assertEquals($secondModel->order_column, 3); 327 | } 328 | 329 | /** @test */ 330 | public function it_will_not_break_when_it_cant_move_the_order_up() 331 | { 332 | $lastModel = Dummy::first(); 333 | 334 | $this->assertEquals($lastModel->order_column, 1); 335 | $this->assertEquals($lastModel, $lastModel->moveOrderUp()); 336 | } 337 | 338 | /** @test */ 339 | public function it_can_swap_the_position_of_two_given_models() 340 | { 341 | $firstModel = Dummy::find(3); 342 | $secondModel = Dummy::find(4); 343 | 344 | $this->assertEquals($firstModel->order_column, 3); 345 | $this->assertEquals($secondModel->order_column, 4); 346 | 347 | Dummy::swapOrder($firstModel, $secondModel); 348 | 349 | $this->assertEquals($firstModel->order_column, 4); 350 | $this->assertEquals($secondModel->order_column, 3); 351 | } 352 | 353 | /** @test */ 354 | public function it_can_swap_itself_with_another_model() 355 | { 356 | $firstModel = Dummy::find(3); 357 | $secondModel = Dummy::find(4); 358 | 359 | $this->assertEquals($firstModel->order_column, 3); 360 | $this->assertEquals($secondModel->order_column, 4); 361 | 362 | $firstModel->swapOrderWithModel($secondModel); 363 | 364 | $this->assertEquals($firstModel->order_column, 4); 365 | $this->assertEquals($secondModel->order_column, 3); 366 | } 367 | 368 | /** @test */ 369 | public function it_can_move_a_model_to_the_first_place() 370 | { 371 | $position = 3; 372 | 373 | $oldModels = Dummy::whereNot('id', $position)->get(); 374 | 375 | $model = Dummy::find($position); 376 | 377 | $this->assertEquals(3, $model->order_column); 378 | 379 | $model = $model->moveToStart(); 380 | 381 | $this->assertEquals(1, $model->order_column); 382 | 383 | $oldModels = $oldModels->pluck('order_column', 'id'); 384 | $newModels = Dummy::whereNot('id', $position)->get()->pluck('order_column', 'id'); 385 | 386 | foreach ($oldModels as $key => $oldModel) { 387 | $this->assertEquals($oldModel + 1, $newModels[$key]); 388 | } 389 | } 390 | 391 | /** 392 | * @test 393 | */ 394 | public function it_can_move_a_model_to_the_last_place() 395 | { 396 | $position = 3; 397 | 398 | $oldModels = Dummy::whereNot('id', $position)->get(); 399 | 400 | $model = Dummy::find($position); 401 | 402 | $this->assertNotEquals(20, $model->order_column); 403 | 404 | $model = $model->moveToEnd(); 405 | 406 | $this->assertEquals(20, $model->order_column); 407 | 408 | $oldModels = $oldModels->pluck('order_column', 'id'); 409 | 410 | $newModels = Dummy::whereNot('id', $position)->get()->pluck('order_column', 'id'); 411 | 412 | foreach ($oldModels as $key => $order) { 413 | if ($order > $position) { 414 | $this->assertEquals($order - 1, $newModels[$key]); 415 | } else { 416 | $this->assertEquals($order, $newModels[$key]); 417 | } 418 | } 419 | } 420 | 421 | /** @test */ 422 | public function it_can_use_config_properties() 423 | { 424 | config([ 425 | 'eloquent-sortable.order_column_name' => 'order_column', 426 | 'eloquent-sortable.sort_when_creating' => true, 427 | ]); 428 | 429 | $model = new class () extends Dummy { 430 | public $sortable = []; 431 | }; 432 | 433 | $this->assertEquals(config('eloquent-sortable.order_column_name'), $model->determineOrderColumnName()); 434 | $this->assertEquals(config('eloquent-sortable.sort_when_creating'), $model->shouldSortWhenCreating()); 435 | } 436 | 437 | /** @test */ 438 | public function it_can_override_config_properties() 439 | { 440 | $model = new class () extends Dummy { 441 | public $sortable = [ 442 | 'order_column_name' => 'my_custom_order_column', 443 | 'sort_when_creating' => false, 444 | ]; 445 | }; 446 | 447 | $this->assertEquals($model->determineOrderColumnName(), 'my_custom_order_column'); 448 | $this->assertFalse($model->shouldSortWhenCreating()); 449 | } 450 | 451 | /** @test */ 452 | public function it_can_tell_if_element_is_first_in_order() 453 | { 454 | $model = (new Dummy())->buildSortQuery()->get(); 455 | $this->assertTrue($model[0]->isFirstInOrder()); 456 | $this->assertFalse($model[1]->isFirstInOrder()); 457 | } 458 | 459 | /** @test */ 460 | public function it_can_tell_if_element_is_last_in_order() 461 | { 462 | $model = (new Dummy())->buildSortQuery()->get(); 463 | $this->assertTrue($model[$model->count() - 1]->isLastInOrder()); 464 | $this->assertFalse($model[$model->count() - 2]->isLastInOrder()); 465 | } 466 | } 467 | --------------------------------------------------------------------------------