├── .github └── FUNDING.yml ├── .gitignore ├── LICENCE ├── README.md ├── composer.json ├── phpstan.neon └── src ├── NestableCollection.php └── NestableTrait.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: typicms 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2025 Samuel De Backer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestableCollection 2 | 3 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://github.com/TypiCMS/NestableCollection/blob/master/LICENCE) 4 | 5 | A Laravel Package that extends collections to handle nested items following adjacency list model. 6 | 7 | ## Installation 8 | 9 | Run `composer require typicms/nestablecollection` 10 | 11 | ## Usage 12 | 13 | The model must have a **parent_id** attributes : 14 | 15 | ```php 16 | protected $fillable = [ 17 | 'parent_id', 18 | // … 19 | ]; 20 | ``` 21 | 22 | and must use the following trait: 23 | 24 | ```php 25 | use TypiCMS\NestableTrait; 26 | ``` 27 | 28 | Now each time you get a collection of that model, it will be an instance of **TypiCMS\NestableCollection** in place of **Illuminate\Database\Eloquent\Collection**. 29 | 30 | If you want a tree of models, simply call the nest method on a collection ordered by parent_id asc : 31 | 32 | ```php 33 | Model::orderBy('parent_id')->get()->nest(); 34 | ``` 35 | 36 | Of course you will probably want a position column as well. So you will have to order first by parent_id asc and then by position asc. 37 | 38 | ## Change the name of subcollections 39 | 40 | By default, the name of the subcollections is **items**, but you can change it by calling the `childrenName($name)` method : 41 | For example if you want your subcollections being named **children**: 42 | 43 | ```php 44 | $collection->childrenName('children')->nest(); 45 | ``` 46 | 47 | ## Indented and flattened list 48 | 49 | `listsFlattened()` method generate the tree as a flattened list with id as keys and title as values, perfect for select/option, for example : 50 | 51 | ```php 52 | [ 53 | '22' => 'Item 1 Title', 54 | '10' => ' Child 1 Title', 55 | '17' => ' Child 2 Title', 56 | '14' => 'Item 2 Title', 57 | ] 58 | ``` 59 | 60 | To use it, first call the `nest()` method, followed by the `listsFlattened()` method: 61 | 62 | ```php 63 | Model::orderBy('parent_id')->get()->nest()->listsFlattened(); 64 | ``` 65 | 66 | By default it will look for a `title` column. You can send a custom column name as first parameter: 67 | 68 | ```php 69 | Model::orderBy('parent_id')->get()->nest()->listsFlattened('name'); 70 | ``` 71 | 72 | Four spaces are used to indent by default, to use your own use the `setIndent()` method, followed by the `listsFlattened()` method: 73 | 74 | ```php 75 | Model::orderBy('parent_id')->get()->nest()->setIndent('> ')->listsFlattened(); 76 | ``` 77 | 78 | Results: 79 | 80 | ```php 81 | [ 82 | '22' => 'Item 1 Title', 83 | '10' => '> Child 1 Title', 84 | '17' => '> Child 2 Title', 85 | '14' => 'Item 2 Title', 86 | ] 87 | ``` 88 | 89 | ## Nesting a subtree 90 | 91 | This package remove items that have missing ancestor, this doesn’t allow you to nest a branch of a tree. 92 | To avoid this, you can use the `noCleaning()` method: 93 | 94 | ```php 95 | Model::orderBy('parent_id')->get()->noCleaning()->nest(); 96 | ``` 97 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typicms/nestablecollection", 3 | "description": "A Laravel Package that extends Collection to handle unlimited nested items following adjacency list model.", 4 | "keywords": [ 5 | "Laravel", 6 | "Eloquent", 7 | "Collection", 8 | "TypiCMS", 9 | "tree", 10 | "nested set", 11 | "adjacency list" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Samuel De Backer", 17 | "email": "sdebacker@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "illuminate/database": "~12.0", 22 | "illuminate/support": "~12.0" 23 | }, 24 | "require-dev": { 25 | "illuminate/database": "~12.0", 26 | "illuminate/support": "~12.0", 27 | "nunomaduro/larastan": "^3.1", 28 | "orchestra/testbench": "^10.0" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "TypiCMS\\": "src/" 33 | } 34 | }, 35 | "minimum-stability": "stable" 36 | } 37 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | 9 | # The level 9 is the highest level 10 | level: 5 11 | 12 | checkGenericClassInNonGenericObjectType: false 13 | -------------------------------------------------------------------------------- /src/NestableCollection.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace TypiCMS; 11 | 12 | use Illuminate\Database\Eloquent\Collection; 13 | use Illuminate\Support\Arr; 14 | use Illuminate\Support\Collection as BaseCollection; 15 | 16 | class NestableCollection extends Collection 17 | { 18 | protected int $total; 19 | 20 | protected string $parentColumn; 21 | 22 | protected bool $removeItemsWithMissingAncestor = true; 23 | 24 | protected string $indentChars = '    '; 25 | 26 | protected string $childrenName = 'items'; 27 | 28 | protected string $parentRelation = 'parent'; 29 | 30 | public function __construct(array $items = []) 31 | { 32 | parent::__construct($items); 33 | $this->parentColumn = 'parent_id'; 34 | $this->total = count($items); 35 | } 36 | 37 | public function parentColumn($name) 38 | { 39 | $this->parentColumn = $name; 40 | 41 | return $this; 42 | } 43 | 44 | public function childrenName(string $name): self 45 | { 46 | $this->childrenName = $name; 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * Nest items. 53 | */ 54 | public function nest(): self 55 | { 56 | $parentColumn = $this->parentColumn; 57 | if (!$parentColumn) { 58 | return $this; 59 | } 60 | 61 | // Set id as keys. 62 | $this->items = $this->getDictionary(); 63 | 64 | $keysToDelete = []; 65 | 66 | // Add empty collection to each items. 67 | $collection = $this->each(function ($item) { 68 | if (!$item->{$this->childrenName}) { 69 | $item->{$this->childrenName} = app()->make('Illuminate\Support\Collection'); 70 | } 71 | }); 72 | 73 | // Remove items with missing ancestor. 74 | if ($this->removeItemsWithMissingAncestor) { 75 | $collection = $this->reject(function ($item) use ($parentColumn) { 76 | if ($item->{$parentColumn}) { 77 | $missingAncestor = $this->anAncestorIsMissing($item); 78 | 79 | return $missingAncestor; 80 | } 81 | }); 82 | } 83 | 84 | // Add items to children collection. 85 | foreach ($collection->items as $item) { 86 | if ($item->{$parentColumn} && isset($collection[$item->{$parentColumn}])) { 87 | $collection[$item->{$parentColumn}]->{$this->childrenName}->push($item); 88 | // @phpstan-ignore-next-line 89 | $keysToDelete[] = $item->id; 90 | } 91 | } 92 | 93 | // Delete moved items. 94 | $this->items = array_values(Arr::except($collection->items, $keysToDelete)); 95 | 96 | return $this; 97 | } 98 | 99 | /** 100 | * Recursive function that flatten a nested Collection 101 | * with characters (default is four spaces). 102 | */ 103 | public function listsFlattened(string $column = 'title', BaseCollection $collection = null, int $level = 0, array &$flattened = [], ?string $indentChars = null, mixed $parentString = null): array 104 | { 105 | $collection = $collection ?: $this; 106 | $indentChars = $indentChars ?: $this->indentChars; 107 | foreach ($collection as $item) { 108 | if ($parentString) { 109 | $item_string = ($parentString === true) ? $item->{$column} : $parentString . $indentChars . $item->{$column}; 110 | } else { 111 | $item_string = str_repeat($indentChars, $level) . $item->{$column}; 112 | } 113 | 114 | $flattened[$item->id] = $item_string; 115 | if ($item->{$this->childrenName}) { 116 | $this->listsFlattened($column, $item->{$this->childrenName}, $level + 1, $flattened, $indentChars, ($parentString) ? $item_string : null); 117 | } 118 | } 119 | 120 | return $flattened; 121 | } 122 | 123 | /** 124 | * Returns a fully qualified version of listsFlattened. 125 | */ 126 | public function listsFlattenedQualified(string $column = 'title', BaseCollection $collection = null, int $level = 0, array &$flattened = [], ?string $indentChars = null): array 127 | { 128 | return $this->listsFlattened($column, $collection, $level, $flattened, $indentChars, true); 129 | } 130 | 131 | /** 132 | * Change the default indent characters when flattening lists. 133 | */ 134 | public function setIndent(string $indentChars): self 135 | { 136 | $this->indentChars = $indentChars; 137 | 138 | return $this; 139 | } 140 | 141 | /** 142 | * Force keeping items that have a missing ancestor. 143 | */ 144 | public function noCleaning(): self 145 | { 146 | $this->removeItemsWithMissingAncestor = false; 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * Check if an ancestor is missing. 153 | */ 154 | public function anAncestorIsMissing(mixed $item): bool 155 | { 156 | $parentColumn = $this->parentColumn; 157 | if (!$item->{$parentColumn}) { 158 | return false; 159 | } 160 | if (!$this->has($item->{$parentColumn})) { 161 | return true; 162 | } 163 | $parent = $this[$item->{$parentColumn}]; 164 | 165 | return $this->anAncestorIsMissing($parent); 166 | } 167 | 168 | /** 169 | * Get total items in nested collection. 170 | */ 171 | public function total(): int 172 | { 173 | return $this->total; 174 | } 175 | 176 | /** 177 | * Get total items for laravel 4 compatibility. 178 | */ 179 | public function getTotal(): int 180 | { 181 | return $this->total(); 182 | } 183 | 184 | /** 185 | * Sets the $item->parent relation for each item in the 186 | * NestableCollection to be the parent it has in the collection 187 | * so it can be used without querying the database. 188 | */ 189 | public function setParents(): self 190 | { 191 | $this->setParentsRecursive($this); 192 | 193 | return $this; 194 | } 195 | 196 | protected function setParentsRecursive(BaseCollection &$items, &$parent = null): void 197 | { 198 | foreach ($items as $item) { 199 | if ($parent) { 200 | $item->setRelation($this->parentRelation, $parent); 201 | } 202 | $this->setParentsRecursive($item->{$this->childrenName}, $item); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/NestableTrait.php: -------------------------------------------------------------------------------- 1 |