├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── config └── config.php ├── database └── migrations │ ├── 2020_01_01_000001_create_categories_table.php │ └── 2020_01_01_000002_create_categorizables_table.php ├── phpstan.neon.dist └── src ├── Console └── Commands │ ├── MigrateCommand.php │ ├── PublishCommand.php │ └── RollbackCommand.php ├── Models └── Category.php ├── Providers └── CategoriesServiceProvider.php └── Traits └── Categorizable.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Rinvex Categories Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [Semantic Versioning](CONTRIBUTING.md). 6 | 7 | 8 | ## [v7.1.2] - 2023-07-03 9 | - Update composer dependencies 10 | - Use canonicalized absolute pathnames for resources 11 | 12 | ## [v7.1.1] - 2023-06-29 13 | - Refactor resource loading and publishing 14 | 15 | ## [v7.1.0] - 2023-05-02 16 | - a8a7560: Add support for Laravel v11, and drop support for Laravel v9 17 | - 3fc3256: Upgrade spatie/laravel-translatable to v6.5 from v6.0 18 | - 19e434a: Upgrade spatie/laravel-sluggable to v3.4 from v3.3 19 | - b046d70: Update phpunit to v10.1 from v9.5 20 | 21 | ## [v7.0.0] - 2023-01-09 22 | - Add Relation::morphMap 23 | - Tweak artisan commands registration 24 | - Drop PHP v8.0 support and update composer dependencies 25 | - Utilize PHP 8.1 attributes feature for artisan commands 26 | 27 | ## [v6.1.2] - 2022-08-30 28 | - Update exists and unique validation rules to use models instead of tables 29 | 30 | ## [v6.1.1] - 2022-06-20 31 | - Update composer dependency spatie/laravel-translatable to v6.0.0 from v5.2.0 (closes #124 #126 #127) 32 | 33 | ## [v6.1.0] - 2022-02-14 34 | - Update composer dependencies to Laravel v9 35 | - Add support for model HasFactory 36 | 37 | ## [v6.0.0] - 2021-08-22 38 | - Drop PHP v7 support, and upgrade rinvex package dependencies to next major version 39 | - Update composer dependencies 40 | 41 | ## [v5.0.6] - 2021-07-22 42 | - Upgrade composer dependency kalnoy/nestedset to v6 43 | 44 | ## [v5.0.5] - 2021-05-24 45 | - Merge rules instead of resetting, to allow adequate model override 46 | - Update spatie/laravel-translatable composer package to v5.0.0 47 | - Update spatie/laravel-sluggable composer package to v3.0.0 48 | 49 | ## [v5.0.4] - 2021-05-11 50 | - Fix constructor initialization order (fill attributes should come next after merging fillables & rules) 51 | 52 | ## [v5.0.3] - 2021-05-07 53 | - Drop old MySQL versions support that doesn't support json columns 54 | - Utilize SoftDeletes 55 | 56 | ## [v5.0.2] - 2021-02-06 57 | - Simplify service provider model registration into IoC 58 | - Enable StyleCI risky mode 59 | 60 | ## [v5.0.1] - 2020-12-25 61 | - Add support for PHP v8 62 | 63 | ## [v5.0.0] - 2020-12-22 64 | - Upgrade to Laravel v8 65 | - Move custom eloquent model events to module layer from core package layer 66 | - Refactor and tweak Eloquent Events 67 | 68 | ## [v4.1.1] - 2020-07-16 69 | - Update validation rules 70 | 71 | ## [v4.1.0] - 2020-06-15 72 | - Remove confusing readme SoftDeletes statement SoftDeletes make more sense to be supported on the application layer 73 | - Improve categories integer IDs processing 74 | - Fix attaching categories by their IDs where IDs are passed mistakenly as strings in some cases! (Fixes #17 #34) 75 | - Fix attaching categories by slugs / strings (Fixes #17 #20 #22) 76 | - Drop legacy code related to rinvex/laravel-cacheable 77 | - Drop using rinvex/laravel-cacheable from core packages for more flexibility 78 | - Caching should be handled on the application layer, not enforced from the core packages 79 | - Drop PHP 7.2 & 7.3 support from travis 80 | 81 | ## [v4.0.6] - 2020-05-30 82 | - Remove default indent size config 83 | - Add strip_tags validation rule to string fields 84 | - Specify events queue 85 | - Explicitly specify relationship attributes 86 | - Add strip_tags validation rule 87 | - Explicitly define relationship name 88 | 89 | ## [v4.0.5] - 2020-04-12 90 | - Fix ServiceProvider registerCommands method compatibility 91 | 92 | ## [v4.0.4] - 2020-04-09 93 | - Tweak artisan command registration 94 | - Reverse commit "Convert database int fields into bigInteger" 95 | - Refactor publish command and allow multiple resource values 96 | 97 | ## [v4.0.3] - 2020-04-04 98 | - Fix namespace issue 99 | 100 | ## [v4.0.2] - 2020-04-04 101 | - Enforce consistent artisan command tag namespacing 102 | - Enforce consistent package namespace 103 | - Drop laravel/helpers usage as it's no longer used 104 | 105 | ## [v4.0.1] - 2020-03-20 106 | - Convert into bigInteger database fields 107 | - Add shortcut -f (force) for artisan publish commands 108 | - Fix migrations path 109 | 110 | ## [v4.0.0] - 2020-03-15 111 | - Upgrade to Laravel v7.1.x & PHP v7.4.x 112 | 113 | ## [v3.0.5] - 2020-03-13 114 | - Tweak TravisCI config 115 | - Remove indirect composer dependency 116 | - Drop using global helpers 117 | - Update StyleCI config 118 | 119 | ## [v3.0.4] - 2020-01-06 120 | - Fix wrong namespace 121 | 122 | ## [v3.0.3] - 2019-12-18 123 | - Fix `migrate:reset` args as it doesn't accept --step 124 | - Create event classes and map them in the model 125 | 126 | ## [v3.0.2] - 2019-09-24 127 | - Add missing laravel/helpers composer package 128 | 129 | ## [v3.0.1] - 2019-09-23 130 | - Fix outdated package version 131 | 132 | ## [v3.0.0] - 2019-09-23 133 | - Upgrade to Laravel v6 and update dependencies 134 | 135 | ## [v2.1.1] - 2019-06-03 136 | - Enforce latest composer package versions 137 | 138 | ## [v2.1.0] - 2019-06-02 139 | - Update composer deps 140 | - Drop PHP 7.1 travis test 141 | - Refactor migrations and artisan commands, and tweak service provider publishes functionality 142 | 143 | ## [v2.0.0] - 2019-03-03 144 | - Rename environment variable QUEUE_DRIVER to QUEUE_CONNECTION 145 | - Require PHP 7.2 & Laravel 5.8 146 | - Apply PHPUnit 8 updates 147 | - Tweak and simplify FormRequest validations 148 | 149 | ## [v1.0.1] - 2018-12-22 150 | - Update composer dependencies 151 | - Add PHP 7.3 support to travis 152 | - Fix MySQL / PostgreSQL json column compatibility 153 | 154 | ## [v1.0.0] - 2018-10-01 155 | - Enforce Consistency 156 | - Support Laravel 5.7+ 157 | - Rename package to rinvex/laravel-categories 158 | 159 | ## [v0.0.5] - 2018-09-22 160 | - Update travis php versions 161 | - Drop StyleCI multi-language support (paid feature now!) 162 | - Update composer dependencies 163 | - Prepare and tweak testing configuration 164 | - Update StyleCI options 165 | - Update PHPUnit options 166 | - Add category model factory 167 | - Update PHPUnit options 168 | 169 | ## [v0.0.4] - 2018-02-18 170 | - Update supplementary files 171 | - Update composer dependencies 172 | - Add PublishCommand to artisan 173 | - Move slug auto generation to the custom HasSlug trait 174 | - Add Rollback Console Command 175 | - Add PHPUnitPrettyResultPrinter 176 | - Typehint method returns 177 | - Drop useless model contracts (models already swappable through IoC) 178 | - Add Laravel v5.6 support 179 | - Simplify IoC binding 180 | - Add force option to artisan commands 181 | - Drop Laravel 5.5 support 182 | 183 | ## [v0.0.3] - 2017-09-09 184 | - Fix many issues and apply many enhancements 185 | - Rename package rinvex/laravel-categories from rinvex/categorizable 186 | 187 | ## [v0.0.2] - 2017-06-29 188 | - Enforce consistency 189 | - Add Laravel 5.5 support 190 | - Update validation rules 191 | - Replace hardcoded table names 192 | - Tweak model event registration 193 | - Fix wrong slug generation method order 194 | - Enforce more secure approach using model fillable instead of guarded 195 | 196 | ## v0.0.1 - 2017-04-08 197 | - Rename package to "rinvex/categorizable" from "rinvex/category" based on 916d250 198 | 199 | [v7.1.2]: https://github.com/rinvex/laravel-categories/compare/v7.1.1...v7.1.2 200 | [v7.1.1]: https://github.com/rinvex/laravel-categories/compare/v7.1.0...v7.1.1 201 | [v7.1.0]: https://github.com/rinvex/laravel-categories/compare/v7.0.0...v7.1.0 202 | [v7.0.0]: https://github.com/rinvex/laravel-categories/compare/v6.1.2...v7.0.0 203 | [v6.1.2]: https://github.com/rinvex/laravel-categories/compare/v6.1.1...v6.1.2 204 | [v6.1.1]: https://github.com/rinvex/laravel-categories/compare/v6.1.0...v6.1.1 205 | [v6.1.0]: https://github.com/rinvex/laravel-categories/compare/v6.0.0...v6.1.0 206 | [v6.0.0]: https://github.com/rinvex/laravel-categories/compare/v5.0.6...v6.0.0 207 | [v5.0.6]: https://github.com/rinvex/laravel-categories/compare/v5.0.5...v5.0.6 208 | [v5.0.5]: https://github.com/rinvex/laravel-categories/compare/v5.0.4...v5.0.5 209 | [v5.0.4]: https://github.com/rinvex/laravel-categories/compare/v5.0.3...v5.0.4 210 | [v5.0.3]: https://github.com/rinvex/laravel-categories/compare/v5.0.2...v5.0.3 211 | [v5.0.2]: https://github.com/rinvex/laravel-categories/compare/v5.0.1...v5.0.2 212 | [v5.0.1]: https://github.com/rinvex/laravel-categories/compare/v5.0.0...v5.0.1 213 | [v5.0.0]: https://github.com/rinvex/laravel-categories/compare/v4.1.1...v5.0.0 214 | [v4.1.1]: https://github.com/rinvex/laravel-categories/compare/v4.1.0...v4.1.1 215 | [v4.1.0]: https://github.com/rinvex/laravel-categories/compare/v4.0.6...v4.1.0 216 | [v4.0.6]: https://github.com/rinvex/laravel-categories/compare/v4.0.5...v4.0.6 217 | [v4.0.5]: https://github.com/rinvex/laravel-categories/compare/v4.0.4...v4.0.5 218 | [v4.0.4]: https://github.com/rinvex/laravel-categories/compare/v4.0.3...v4.0.4 219 | [v4.0.3]: https://github.com/rinvex/laravel-categories/compare/v4.0.2...v4.0.3 220 | [v4.0.2]: https://github.com/rinvex/laravel-categories/compare/v4.0.1...v4.0.2 221 | [v4.0.1]: https://github.com/rinvex/laravel-categories/compare/v4.0.0...v4.0.1 222 | [v4.0.0]: https://github.com/rinvex/laravel-categories/compare/v3.0.5...v4.0.0 223 | [v3.0.5]: https://github.com/rinvex/laravel-categories/compare/v3.0.4...v3.0.5 224 | [v3.0.4]: https://github.com/rinvex/laravel-categories/compare/v3.0.3...v3.0.4 225 | [v3.0.3]: https://github.com/rinvex/laravel-categories/compare/v3.0.2...v3.0.3 226 | [v3.0.2]: https://github.com/rinvex/laravel-categories/compare/v3.0.1...v3.0.2 227 | [v3.0.1]: https://github.com/rinvex/laravel-categories/compare/v3.0.0...v3.0.1 228 | [v3.0.0]: https://github.com/rinvex/laravel-categories/compare/v2.1.1...v3.0.0 229 | [v2.1.1]: https://github.com/rinvex/laravel-categories/compare/v2.1.0...v2.1.1 230 | [v2.1.0]: https://github.com/rinvex/laravel-categories/compare/v2.0.0...v2.1.0 231 | [v2.0.0]: https://github.com/rinvex/laravel-categories/compare/v1.0.1...v2.0.0 232 | [v1.0.1]: https://github.com/rinvex/laravel-categories/compare/v1.0.0...v1.0.1 233 | [v1.0.0]: https://github.com/rinvex/laravel-categories/compare/v0.0.5...v1.0.0 234 | [v0.0.5]: https://github.com/rinvex/laravel-categories/compare/v0.0.4...v0.0.5 235 | [v0.0.4]: https://github.com/rinvex/laravel-categories/compare/v0.0.3...v0.0.4 236 | [v0.0.3]: https://github.com/rinvex/laravel-categories/compare/v0.0.2...v0.0.3 237 | [v0.0.2]: https://github.com/rinvex/laravel-categories/compare/v0.0.1...v0.0.2 238 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [help@rinvex.com](mailto:help@rinvex.com). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | This project adheres to the following standards and practices. 4 | 5 | 6 | ## Versioning 7 | 8 | This project is versioned under the [Semantic Versioning](http://semver.org/) guidelines as much as possible. 9 | 10 | Releases will be numbered with the following format: 11 | 12 | - `..` 13 | - `..` 14 | 15 | And constructed with the following guidelines: 16 | 17 | - Breaking backward compatibility bumps the major and resets the minor and patch. 18 | - New additions without breaking backward compatibility bump the minor and reset the patch. 19 | - Bug fixes and misc changes bump the patch. 20 | 21 | 22 | ## Pull Requests 23 | 24 | The pull request process differs for new features and bugs. 25 | 26 | Pull requests for bugs may be sent without creating any proposal issue. If you believe that you know of a solution for a bug that has been filed, please leave a comment detailing your proposed fix or create a pull request with the fix mentioning that issue id. 27 | 28 | 29 | ## Coding Standards 30 | 31 | This project follows the FIG PHP Standards Recommendations compliant with the [PSR-1: Basic Coding Standard](http://www.php-fig.org/psr/psr-1/), [PSR-2: Coding Style Guide](http://www.php-fig.org/psr/psr-2/) and [PSR-4: Autoloader](http://www.php-fig.org/psr/psr-4/) to ensure a high level of interoperability between shared PHP code. If you notice any compliance oversights, please send a patch via pull request. 32 | 33 | 34 | ## Feature Requests 35 | 36 | If you have a proposal or a feature request, you may create an issue with `[Proposal]` in the title. 37 | 38 | The proposal should also describe the new feature, as well as implementation ideas. The proposal will then be reviewed and either approved or denied. Once a proposal is approved, a pull request may be created implementing the new feature. 39 | 40 | 41 | ## Git Flow 42 | 43 | This project follows [Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/), and as such has `master` (latest stable releases), `develop` (latest WIP development) and X.Y support branches (when there's multiple major versions). 44 | 45 | Accordingly all pull requests MUST be sent to the `develop` branch. 46 | 47 | > **Note:** Pull requests which do not follow these guidelines will be closed without any further notice. 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2021, Rinvex LLC, 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 | # Rinvex Categories 2 | 3 | **Rinvex Categories** is a polymorphic Laravel package, for category management. You can categorize any eloquent model with ease, and utilize the power of **[Nested Sets](https://github.com/lazychaser/laravel-nestedset)**, and the awesomeness of **[Sluggable](https://github.com/spatie/laravel-sluggable)**, and **[Translatable](https://github.com/spatie/laravel-translatable)** models out of the box. 4 | 5 | [![Packagist](https://img.shields.io/packagist/v/rinvex/laravel-categories.svg?label=Packagist&style=flat-square)](https://packagist.org/packages/rinvex/laravel-categories) 6 | [![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/rinvex/laravel-categories.svg?label=Scrutinizer&style=flat-square)](https://scrutinizer-ci.com/g/rinvex/laravel-categories/) 7 | [![Travis](https://img.shields.io/travis/rinvex/laravel-categories.svg?label=TravisCI&style=flat-square)](https://travis-ci.org/rinvex/laravel-categories) 8 | [![StyleCI](https://styleci.io/repos/87599972/shield)](https://styleci.io/repos/87599972) 9 | [![License](https://img.shields.io/packagist/l/rinvex/laravel-categories.svg?label=License&style=flat-square)](https://github.com/rinvex/laravel-categories/blob/develop/LICENSE) 10 | 11 | 12 | ## Installation 13 | 14 | 1. Install the package via composer: 15 | ```shell 16 | composer require rinvex/laravel-categories 17 | ``` 18 | 19 | 2. Publish resources (migrations and config files): 20 | ```shell 21 | php artisan rinvex:publish:categories 22 | ``` 23 | 24 | 3. Execute migrations via the following command: 25 | ```shell 26 | php artisan rinvex:migrate:categories 27 | ``` 28 | 29 | 4. Done! 30 | 31 | 32 | ## Usage 33 | 34 | To add categories support to your eloquent models simply use `\Rinvex\Categories\Traits\Categorizable` trait. 35 | 36 | ### Manage your categories 37 | 38 | Your categories are just normal [eloquent](https://laravel.com/docs/master/eloquent) models, so you can deal with it like so. Nothing special here! 39 | 40 | > **Notes:** since **Rinvex Categories** extends and utilizes other awesome packages, checkout the following documentations for further details: 41 | > - Powerful Nested Sets using [`kalnoy/nestedset`](https://github.com/lazychaser/laravel-nestedset) 42 | > - Automatic Slugging using [`spatie/laravel-sluggable`](https://github.com/spatie/laravel-sluggable) 43 | > - Translatable out of the box using [`spatie/laravel-translatable`](https://github.com/spatie/laravel-translatable) 44 | 45 | ### Manage your categorizable model 46 | 47 | The API is intutive and very straightforward, so let's give it a quick look: 48 | 49 | ```php 50 | // Get all categories 51 | $allCategories = app('rinvex.categories.category')->all(); 52 | 53 | // Get instance of your model 54 | $post = new \App\Models\Post::find(123); 55 | 56 | // Get attached categories collection 57 | $post->categories; 58 | 59 | // Get attached categories query builder 60 | $post->categories(); 61 | ``` 62 | 63 | You can attach categories in various ways: 64 | 65 | ```php 66 | // Single category id 67 | $post->attachCategories(1); 68 | 69 | // Multiple category IDs array 70 | $post->attachCategories([1, 2, 5]); 71 | 72 | // Multiple category IDs collection 73 | $post->attachCategories(collect([1, 2, 5])); 74 | 75 | // Single category model instance 76 | $categoryInstance = app('rinvex.categories.category')->first(); 77 | $post->attachCategories($categoryInstance); 78 | 79 | // Single category slug 80 | $post->attachCategories('test-category'); 81 | 82 | // Multiple category slugs array 83 | $post->attachCategories(['first-category', 'second-category']); 84 | 85 | // Multiple category slugs collection 86 | $post->attachCategories(collect(['first-category', 'second-category'])); 87 | 88 | // Multiple category model instances 89 | $categoryInstances = app('rinvex.categories.category')->whereIn('id', [1, 2, 5])->get(); 90 | $post->attachCategories($categoryInstances); 91 | ``` 92 | 93 | > **Notes:** 94 | > - The `attachCategories()` method attach the given categories to the model without touching the currently attached categories, while there's the `syncCategories()` method that can detach any records that's not in the given items, this method takes a second optional boolean parameter that's set detaching flag to `true` or `false`. 95 | > - To detach model categories you can use the `detachCategories()` method, which uses **exactly** the same signature as the `attachCategories()` method, with additional feature of detaching all currently attached categories by passing null or nothing to that method as follows: `$post->detachCategories();`. 96 | 97 | And as you may have expected, you can check if categories attached: 98 | 99 | ```php 100 | // Single category id 101 | $post->hasAnyCategories(1); 102 | 103 | // Multiple category IDs array 104 | $post->hasAnyCategories([1, 2, 5]); 105 | 106 | // Multiple category IDs collection 107 | $post->hasAnyCategories(collect([1, 2, 5])); 108 | 109 | // Single category model instance 110 | $categoryInstance = app('rinvex.categories.category')->first(); 111 | $post->hasAnyCategories($categoryInstance); 112 | 113 | // Single category slug 114 | $post->hasAnyCategories('test-category'); 115 | 116 | // Multiple category slugs array 117 | $post->hasAnyCategories(['first-category', 'second-category']); 118 | 119 | // Multiple category slugs collection 120 | $post->hasAnyCategories(collect(['first-category', 'second-category'])); 121 | 122 | // Multiple category model instances 123 | $categoryInstances = app('rinvex.categories.category')->whereIn('id', [1, 2, 5])->get(); 124 | $post->hasAnyCategories($categoryInstances); 125 | ``` 126 | 127 | > **Notes:** 128 | > - The `hasAnyCategories()` method check if **ANY** of the given categories are attached to the model. It returns boolean `true` or `false` as a result. 129 | > - Similarly the `hasAllCategories()` method uses **exactly** the same signature as the `hasAnyCategories()` method, but it behaves differently and performs a strict comparison to check if **ALL** of the given categories are attached. 130 | 131 | ### Advanced usage 132 | 133 | #### Generate category slugs 134 | 135 | **Rinvex Categories** auto generates slugs and auto detect and insert default translation for you if not provided, but you still can pass it explicitly through normal eloquent `create` method, as follows: 136 | 137 | ```php 138 | app('rinvex.categories.category')->create(['name' => ['en' => 'My New Category'], 'slug' => 'custom-category-slug']); 139 | ``` 140 | 141 | > **Note:** Check **[Sluggable](https://github.com/spatie/laravel-sluggable)** package for further details. 142 | 143 | #### Smart parameter detection 144 | 145 | **Rinvex Categories** methods that accept list of categories are smart enough to handle almost all kinds of inputs as you've seen in the above examples. It will check input type and behave accordingly. 146 | 147 | #### Retrieve all models attached to the category 148 | 149 | You may encounter a situation where you need to get all models attached to certain category, you do so with ease as follows: 150 | 151 | ```php 152 | $category = app('rinvex.categories.category')->find(1); 153 | $category->entries(\App\Models\Post::class)->get(); 154 | ``` 155 | 156 | #### Query scopes 157 | 158 | Yes, **Rinvex Categories** shipped with few awesome query scopes for your convenience, usage example: 159 | 160 | ```php 161 | // Single category id 162 | $post->withAnyCategories(1)->get(); 163 | 164 | // Multiple category IDs array 165 | $post->withAnyCategories([1, 2, 5])->get(); 166 | 167 | // Multiple category IDs collection 168 | $post->withAnyCategories(collect([1, 2, 5]))->get(); 169 | 170 | // Single category model instance 171 | $categoryInstance = app('rinvex.categories.category')->first(); 172 | $post->withAnyCategories($categoryInstance)->get(); 173 | 174 | // Single category slug 175 | $post->withAnyCategories('test-category')->get(); 176 | 177 | // Multiple category slugs array 178 | $post->withAnyCategories(['first-category', 'second-category'])->get(); 179 | 180 | // Multiple category slugs collection 181 | $post->withAnyCategories(collect(['first-category', 'second-category']))->get(); 182 | 183 | // Multiple category model instances 184 | $categoryInstances = app('rinvex.categories.category')->whereIn('id', [1, 2, 5])->get(); 185 | $post->withAnyCategories($categoryInstances)->get(); 186 | ``` 187 | 188 | > **Notes:** 189 | > - The `withAnyCategories()` scope finds posts with **ANY** attached categories of the given. It returns normally a query builder, so you can chain it or call `get()` method for example to execute and get results. 190 | > - Similarly there's few other scopes like `withAllCategories()` that finds posts with **ALL** attached categories of the given, `withoutCategories()` which finds posts without **ANY** attached categories of the given, and lastly `withoutAnyCategories()` which find posts without **ANY** attached categories at all. All scopes are created equal, with same signature, and returns query builder. 191 | 192 | #### Category translations 193 | 194 | Manage category translations with ease as follows: 195 | 196 | ```php 197 | $category = app('rinvex.categories.category')->find(1); 198 | 199 | // Update title translations 200 | $category->setTranslation('name', 'en', 'New English Category Title')->save(); 201 | 202 | // Alternatively you can use default eloquent update 203 | $category->update([ 204 | 'name' => [ 205 | 'en' => 'New Category', 206 | 'ar' => 'تصنيف جديد', 207 | ], 208 | ]); 209 | 210 | // Get single category translation 211 | $category->getTranslation('name', 'en'); 212 | 213 | // Get all category translations 214 | $category->getTranslations('name'); 215 | 216 | // Get category title in default locale 217 | $category->name; 218 | ``` 219 | 220 | > **Note:** Check **[Translatable](https://github.com/spatie/laravel-translatable)** package for further details. 221 | 222 | ___ 223 | 224 | ## Manage your nodes/nestedsets 225 | 226 | - [Inserting Categories](#inserting-categories) 227 | - [Creating categories](#creating-categories) 228 | - [Making a root from existing category](#making-a-root-from-existing-category) 229 | - [Appending and prepending to the specified parent](#appending-and-prepending-to-the-specified-parent) 230 | - [Inserting before or after specified category](#inserting-before-or-after-specified-category) 231 | - [Building a tree from array](#building-a-tree-from-array) 232 | - [Rebuilding a tree from array](#rebuilding-a-tree-from-array) 233 | - [Retrieving categories](#retrieving-categories) 234 | - [Ancestors](#ancestors) 235 | - [Descendants](#descendants) 236 | - [Siblings](#siblings) 237 | - [Getting related models from other table](#getting-related-models-from-other-table) 238 | - [Including category depth](#including-category-depth) 239 | - [Default order](#default-order) 240 | - [Shifting a category](#shifting-a-category) 241 | - [Constraints](#constraints) 242 | - [Building a tree](#building-a-tree) 243 | - [Building flat tree](#building-flat-tree) 244 | - [Getting a subtree](#getting-a-subtree) 245 | - [Deleting categories](#deleting-categories) 246 | - [Helper methods](#helper-methods) 247 | - [Checking consistency](#checking-consistency) 248 | - [Fixing tree](#fixing-tree) 249 | 250 | ### Inserting categories 251 | 252 | Moving and inserting categories includes several database queries, so **transaction is automatically started** 253 | when category is saved. It is safe to use global transaction if you work with several models. 254 | 255 | Another important note is that **structural manipulations are deferred** until you hit `save` on model 256 | (some methods implicitly call `save` and return boolean result of the operation). 257 | 258 | If model is successfully saved it doesn't mean that category was moved. If your application 259 | depends on whether the category has actually changed its position, use `hasMoved` method: 260 | 261 | ```php 262 | if ($category->save()) { 263 | $moved = $category->hasMoved(); 264 | } 265 | ``` 266 | 267 | #### Creating categories 268 | 269 | When you simply create a category, it will be appended to the end of the tree: 270 | 271 | ```php 272 | app('rinvex.categories.category')->create($attributes); // Saved as root 273 | 274 | $category = app('rinvex.categories.category')->fill($attributes); 275 | $category->save(); // Saved as root 276 | ``` 277 | 278 | In this case the category is considered a _root_ which means that it doesn't have a parent. 279 | 280 | #### Making a root from existing category 281 | 282 | The category will be appended to the end of the tree: 283 | 284 | ```php 285 | // #1 Implicit save 286 | $category->saveAsRoot(); 287 | 288 | // #2 Explicit save 289 | $category->makeRoot()->save(); 290 | ``` 291 | 292 | #### Appending and prepending to the specified parent 293 | 294 | If you want to make category a child of other category, you can make it last or first child. 295 | Suppose that `$parent` is some existing category, there are few ways to append a category: 296 | 297 | ```php 298 | // #1 Using deferred insert 299 | $category->appendToNode($parent)->save(); 300 | 301 | // #2 Using parent category 302 | $parent->appendNode($category); 303 | 304 | // #3 Using parent's children relationship 305 | $parent->children()->create($attributes); 306 | 307 | // #5 Using category's parent relationship 308 | $category->parent()->associate($parent)->save(); 309 | 310 | // #6 Using the parent attribute 311 | $category->parent_id = $parent->getKey(); 312 | $category->save(); 313 | 314 | // #7 Using static method 315 | app('rinvex.categories.category')->create($attributes, $parent); 316 | ``` 317 | 318 | And only a couple ways to prepend: 319 | 320 | ```php 321 | // #1 Using deferred insert 322 | $category->prependToNode($parent)->save(); 323 | 324 | // #2 Using parent category 325 | $parent->prependNode($category); 326 | ``` 327 | 328 | #### Inserting before or after specified category 329 | 330 | You can make `$category` to be a neighbor of the `$neighbor` category. 331 | Suppose that `$neighbor` is some existing category, while target category can be fresh. 332 | If target category exists, it will be moved to the new position and parent will be changed if it's required. 333 | 334 | ```php 335 | # Explicit save 336 | $category->afterNode($neighbor)->save(); 337 | $category->beforeNode($neighbor)->save(); 338 | 339 | # Implicit save 340 | $category->insertAfterNode($neighbor); 341 | $category->insertBeforeNode($neighbor); 342 | ``` 343 | 344 | #### Building a tree from array 345 | 346 | When using static method `create` on category, it checks whether attributes contains `children` key. 347 | If it does, it creates more categories recursively, as follows: 348 | 349 | ```php 350 | $category = app('rinvex.categories.category')->create([ 351 | 'name' => [ 352 | 'en' => 'New Category Title', 353 | ], 354 | 355 | 'children' => [ 356 | [ 357 | 'name' => 'Bar', 358 | 359 | 'children' => [ 360 | [ 'name' => 'Baz' ], 361 | ], 362 | ], 363 | ], 364 | ]); 365 | ``` 366 | 367 | `$category->children` now contains a list of created child categories. 368 | 369 | #### Rebuilding a tree from array 370 | 371 | You can easily rebuild a tree. This is useful for mass-changing the structure of the tree. 372 | Given the `$data` as an array of categories, you can build the tree as follows: 373 | 374 | ```php 375 | $data = [ 376 | [ 'id' => 1, 'name' => 'foo', 'children' => [ ... ] ], 377 | [ 'name' => 'bar' ], 378 | ]; 379 | 380 | app('rinvex.categories.category')->rebuildTree($data, $delete); 381 | ``` 382 | 383 | There is an id specified for category with the title of `foo` which means that existing 384 | category will be filled and saved. If category does not exists `ModelNotFoundException` is 385 | thrown. Also, this category has `children` specified which is also an array of categories; 386 | they will be processed in the same manner and saved as children of category `foo`. 387 | 388 | Category `bar` has no primary key specified, so it will treated as a new one, and be created. 389 | 390 | `$delete` shows whether to delete categories that are already exists but not present 391 | in `$data`. By default, categories aren't deleted. 392 | 393 | ### Retrieving categories 394 | 395 | _In some cases we will use an `$id` variable which is an id of the target category._ 396 | 397 | #### Ancestors 398 | 399 | Ancestors make a chain of parents to the category. 400 | Helpful for displaying breadcrumbs to the current category. 401 | 402 | ```php 403 | // #1 Using accessor 404 | $result = $category->getAncestors(); 405 | 406 | // #2 Using a query 407 | $result = $category->ancestors()->get(); 408 | 409 | // #3 Getting ancestors by primary key 410 | $result = app('rinvex.categories.category')->ancestorsOf($id); 411 | ``` 412 | 413 | #### Descendants 414 | 415 | Descendants are all categories in a sub tree, 416 | i.e. children of category, children of children, etc. 417 | 418 | ```php 419 | // #1 Using relationship 420 | $result = $category->descendants; 421 | 422 | // #2 Using a query 423 | $result = $category->descendants()->get(); 424 | 425 | // #3 Getting descendants by primary key 426 | $result = app('rinvex.categories.category')->descendantsOf($id); 427 | 428 | // #3 Get descendants and the category by id 429 | $result = app('rinvex.categories.category')->descendantsAndSelf($id); 430 | ``` 431 | 432 | Descendants can be eagerly loaded: 433 | 434 | ```php 435 | $categories = app('rinvex.categories.category')->with('descendants')->whereIn('id', $idList)->get(); 436 | ``` 437 | 438 | #### Siblings 439 | 440 | Siblings are categories that have same parent. 441 | 442 | ```php 443 | $result = $category->getSiblings(); 444 | 445 | $result = $category->siblings()->get(); 446 | ``` 447 | 448 | To get only next siblings: 449 | 450 | ```php 451 | // Get a sibling that is immediately after the category 452 | $result = $category->getNextSibling(); 453 | 454 | // Get all siblings that are after the category 455 | $result = $category->getNextSiblings(); 456 | 457 | // Get all siblings using a query 458 | $result = $category->nextSiblings()->get(); 459 | ``` 460 | 461 | To get previous siblings: 462 | 463 | ```php 464 | // Get a sibling that is immediately before the category 465 | $result = $category->getPrevSibling(); 466 | 467 | // Get all siblings that are before the category 468 | $result = $category->getPrevSiblings(); 469 | 470 | // Get all siblings using a query 471 | $result = $category->prevSiblings()->get(); 472 | ``` 473 | 474 | #### Getting related models from other table 475 | 476 | Imagine that each category `has many` products. I.e. `HasMany` relationship is established. 477 | How can you get all products of `$category` and every its descendant? Easy! 478 | 479 | ```php 480 | // Get ids of descendants 481 | $categories = $category->descendants()->pluck('id'); 482 | 483 | // Include the id of category itself 484 | $categories[] = $category->getKey(); 485 | 486 | // Get products 487 | $goods = Product::whereIn('category_id', $categories)->get(); 488 | ``` 489 | 490 | Now imagine that each category `has many` posts. I.e. `morphToMany` relationship is established this time. 491 | How can you get all posts of `$category` and every its descendant? Is that even possible?! Sure! 492 | 493 | ```php 494 | // Get ids of descendants 495 | $categories = $category->descendants()->pluck('id'); 496 | 497 | // Include the id of category itself 498 | $categories[] = $category->getKey(); 499 | 500 | // Get posts 501 | $posts = \App\Models\Post::withCategories($categories)->get(); 502 | ``` 503 | 504 | #### Including category depth 505 | 506 | If you need to know at which level the category is: 507 | 508 | ```php 509 | $result = app('rinvex.categories.category')->withDepth()->find($id); 510 | 511 | $depth = $result->depth; 512 | ``` 513 | 514 | Root category will be at level 0. Children of root categories will have a level of 1, etc. 515 | To get categories of specified level, you can apply `having` constraint: 516 | 517 | ```php 518 | $result = app('rinvex.categories.category')->withDepth()->having('depth', '=', 1)->get(); 519 | ``` 520 | 521 | #### Default order 522 | 523 | Each category has it's own unique `_lft` value that determines its position in the tree. 524 | If you want category to be ordered by this value, you can use `defaultOrder` method 525 | on the query builder: 526 | 527 | ```php 528 | // All categories will now be ordered by lft value 529 | $result = app('rinvex.categories.category')->defaultOrder()->get(); 530 | ``` 531 | 532 | You can get categories in reversed order: 533 | 534 | ```php 535 | $result = app('rinvex.categories.category')->reversed()->get(); 536 | ``` 537 | 538 | ##### Shifting a category 539 | 540 | To shift category up or down inside parent to affect default order: 541 | 542 | ```php 543 | $bool = $category->down(); 544 | $bool = $category->up(); 545 | 546 | // Shift category by 3 siblings 547 | $bool = $category->down(3); 548 | ``` 549 | 550 | The result of the operation is boolean value of whether the category has changed its position. 551 | 552 | #### Constraints 553 | 554 | Various constraints that can be applied to the query builder: 555 | 556 | - **whereIsRoot()** to get only root categories; 557 | - **whereIsAfter($id)** to get every category (not just siblings) that are after a category with specified id; 558 | - **whereIsBefore($id)** to get every category that is before a category with specified id. 559 | 560 | Descendants constraints: 561 | 562 | ```php 563 | $result = app('rinvex.categories.category')->whereDescendantOf($category)->get(); 564 | $result = app('rinvex.categories.category')->whereNotDescendantOf($category)->get(); 565 | $result = app('rinvex.categories.category')->orWhereDescendantOf($category)->get(); 566 | $result = app('rinvex.categories.category')->orWhereNotDescendantOf($category)->get(); 567 | 568 | // Include target category into result set 569 | $result = app('rinvex.categories.category')->whereDescendantOrSelf($category)->get(); 570 | ``` 571 | 572 | Ancestor constraints: 573 | 574 | ```php 575 | $result = app('rinvex.categories.category')->whereAncestorOf($category)->get(); 576 | ``` 577 | 578 | `$category` can be either a primary key of the model or model instance. 579 | 580 | #### Building a tree 581 | 582 | After getting a set of categories, you can convert it to tree. For example: 583 | 584 | ```php 585 | $tree = app('rinvex.categories.category')->get()->toTree(); 586 | ``` 587 | 588 | This will fill `parent` and `children` relationships on every category in the set and 589 | you can render a tree using recursive algorithm: 590 | 591 | ```php 592 | $categories = app('rinvex.categories.category')->get()->toTree(); 593 | 594 | $traverse = function ($categories, $prefix = '-') use (&$traverse) { 595 | foreach ($categories as $category) { 596 | echo PHP_EOL.$prefix.' '.$category->name; 597 | 598 | $traverse($category->children, $prefix.'-'); 599 | } 600 | }; 601 | 602 | $traverse($categories); 603 | ``` 604 | 605 | This will output something like this: 606 | 607 | ``` 608 | - Root 609 | -- Child 1 610 | --- Sub child 1 611 | -- Child 2 612 | - Another root 613 | ``` 614 | 615 | ##### Building flat tree 616 | 617 | Also, you can build a flat tree: a list of categories where child categories are immediately 618 | after parent category. This is helpful when you get categories with custom order 619 | (i.e. alphabetically) and don't want to use recursion to iterate over your categories. 620 | 621 | ```php 622 | $categories = app('rinvex.categories.category')->get()->toFlatTree(); 623 | ``` 624 | 625 | ##### Getting a subtree 626 | 627 | Sometimes you don't need whole tree to be loaded and just some subtree of specific category: 628 | 629 | ```php 630 | $root = app('rinvex.categories.category')->find($rootId); 631 | $tree = $root->descendants->toTree($root); 632 | ``` 633 | 634 | Now `$tree` contains children of `$root` category. 635 | 636 | If you don't need `$root` category itself, do following instead: 637 | 638 | ```php 639 | $tree = app('rinvex.categories.category')->descendantsOf($rootId)->toTree($rootId); 640 | ``` 641 | 642 | ### Deleting categories 643 | 644 | To delete a category: 645 | 646 | ```php 647 | $category->delete(); 648 | ``` 649 | 650 | **IMPORTANT!** Any descendant that category has will also be **deleted**! 651 | 652 | **IMPORTANT!** Categories are required to be deleted as models, **don't** try do delete them using a query like so: 653 | 654 | ```php 655 | app('rinvex.categories.category')->where('id', '=', $id)->delete(); 656 | ``` 657 | 658 | **That will break the tree!** 659 | 660 | 661 | ### Helper methods 662 | 663 | ```php 664 | // Check if category is a descendant of other category 665 | $bool = $category->isDescendantOf($parent); 666 | 667 | // Check whether the category is a root: 668 | $bool = $category->isRoot(); 669 | 670 | // Other checks 671 | $category->isChildOf($other); 672 | $category->isAncestorOf($other); 673 | $category->isSiblingOf($other); 674 | ``` 675 | 676 | ### Checking consistency 677 | 678 | You can check whether a tree is broken (i.e. has some structural errors): 679 | 680 | ```php 681 | // Check if tree is broken 682 | $bool = app('rinvex.categories.category')->isBroken(); 683 | 684 | // Get tree error statistics 685 | $data = app('rinvex.categories.category')->countErrors(); 686 | ``` 687 | 688 | Tree error statistics will return an array with following keys: 689 | 690 | - `oddness` -- the number of categories that have wrong set of `lft` and `rgt` values 691 | - `duplicates` -- the number of categories that have same `lft` or `rgt` values 692 | - `wrong_parent` -- the number of categories that have invalid `parent_id` value that doesn't correspond to `lft` and `rgt` values 693 | - `missing_parent` -- the number of categories that have `parent_id` pointing to category that doesn't exists 694 | 695 | #### Fixing tree 696 | 697 | Category tree can now be fixed if broken. Using inheritance info from `parent_id` column, 698 | proper `_lft` and `_rgt` values are set for every category. 699 | 700 | ```php 701 | app('rinvex.categories.category')->fixTree(); 702 | ``` 703 | 704 | > **Note:** Check **[Nested Sets](https://github.com/lazychaser/laravel-nestedset)** package for further details. 705 | 706 | 707 | ## Changelog 708 | 709 | Refer to the [Changelog](CHANGELOG.md) for a full history of the project. 710 | 711 | 712 | ## Support 713 | 714 | The following support channels are available at your fingertips: 715 | 716 | - [Chat on Slack](https://bit.ly/rinvex-slack) 717 | - [Help on Email](mailto:help@rinvex.com) 718 | - [Follow on Twitter](https://twitter.com/rinvex) 719 | 720 | 721 | ## Contributing & Protocols 722 | 723 | Thank you for considering contributing to this project! The contribution guide can be found in [CONTRIBUTING.md](CONTRIBUTING.md). 724 | 725 | Bug reports, feature requests, and pull requests are very welcome. 726 | 727 | - [Versioning](CONTRIBUTING.md#versioning) 728 | - [Pull Requests](CONTRIBUTING.md#pull-requests) 729 | - [Coding Standards](CONTRIBUTING.md#coding-standards) 730 | - [Feature Requests](CONTRIBUTING.md#feature-requests) 731 | - [Git Flow](CONTRIBUTING.md#git-flow) 732 | 733 | 734 | ## Security Vulnerabilities 735 | 736 | We want to ensure that this package is secure for everyone. If you've discovered a security vulnerability in this package, we appreciate your help in disclosing it to us in a [responsible manner](https://en.wikipedia.org/wiki/Responsible_disclosure). 737 | 738 | Publicly disclosing a vulnerability can put the entire community at risk. If you've discovered a security concern, please email us at [help@rinvex.com](mailto:help@rinvex.com). We'll work with you to make sure that we understand the scope of the issue, and that we fully address your concern. We consider correspondence sent to [help@rinvex.com](mailto:help@rinvex.com) our highest priority, and work to address any issues that arise as quickly as possible. 739 | 740 | After a security vulnerability has been corrected, a security hotfix release will be deployed as soon as possible. 741 | 742 | 743 | ## About Rinvex 744 | 745 | Rinvex is a software solutions startup, specialized in integrated enterprise solutions for SMEs established in Alexandria, Egypt since June 2016. We believe that our drive The Value, The Reach, and The Impact is what differentiates us and unleash the endless possibilities of our philosophy through the power of software. We like to call it Innovation At The Speed Of Life. That’s how we do our share of advancing humanity. 746 | 747 | 748 | ## License 749 | 750 | This software is released under [The MIT License (MIT)](LICENSE). 751 | 752 | (c) 2016-2022 Rinvex LLC, Some rights reserved. 753 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rinvex/laravel-categories", 3 | "description": "Rinvex Categories is a polymorphic Laravel package, for category management. You can categorize any eloquent model with ease, and utilize the power of Nested Sets, and the awesomeness of Sluggable, and Translatable models out of the box.", 4 | "type": "library", 5 | "keywords": [ 6 | "model", 7 | "laravel", 8 | "eloquent", 9 | "category", 10 | "sluggable", 11 | "translatable", 12 | "categorizable", 13 | "polymorphic", 14 | "nested-set", 15 | "taxonomy", 16 | "rinvex" 17 | ], 18 | "license": "MIT", 19 | "homepage": "https://rinvex.com", 20 | "support": { 21 | "email": "help@rinvex.com", 22 | "issues": "https://github.com/rinvex/laravel-categories/issues", 23 | "source": "https://github.com/rinvex/laravel-categories", 24 | "docs": "https://github.com/rinvex/laravel-categories/blob/master/README.md" 25 | }, 26 | "authors": [ 27 | { 28 | "name": "Rinvex LLC", 29 | "homepage": "https://rinvex.com", 30 | "email": "help@rinvex.com" 31 | }, 32 | { 33 | "name": "Abdelrahman Omran", 34 | "homepage": "https://omranic.com", 35 | "email": "me@omranic.com", 36 | "role": "Project Lead" 37 | }, 38 | { 39 | "name": "The Generous Laravel Community", 40 | "homepage": "https://github.com/rinvex/laravel-categories/contributors" 41 | } 42 | ], 43 | "require": { 44 | "php": "^8.1.0", 45 | "illuminate/console": "^10.0.0 || ^11.0.0", 46 | "illuminate/database": "^10.0.0 || ^11.0.0", 47 | "illuminate/support": "^10.0.0 || ^11.0.0", 48 | "kalnoy/nestedset": "^6.0.0", 49 | "rinvex/laravel-support": "^7.0.0", 50 | "spatie/laravel-sluggable": "^3.4.0", 51 | "symfony/console": "^6.2.0" 52 | }, 53 | "require-dev": { 54 | "codedungeon/phpunit-result-printer": "^0.32.0", 55 | "illuminate/container": "^10.0.0 || ^11.0.0", 56 | "phpunit/phpunit": "^10.1.0" 57 | }, 58 | "autoload": { 59 | "psr-4": { 60 | "Rinvex\\Categories\\": "src/" 61 | } 62 | }, 63 | "autoload-dev": { 64 | "psr-4": { 65 | "Rinvex\\Categories\\Tests\\": "tests" 66 | } 67 | }, 68 | "scripts": { 69 | "test": "vendor/bin/phpunit" 70 | }, 71 | "config": { 72 | "sort-packages": true, 73 | "preferred-install": "dist", 74 | "optimize-autoloader": true 75 | }, 76 | "extra": { 77 | "laravel": { 78 | "providers": [ 79 | "Rinvex\\Categories\\Providers\\CategoriesServiceProvider" 80 | ] 81 | } 82 | }, 83 | "minimum-stability": "dev", 84 | "prefer-stable": true 85 | } 86 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | true, 9 | 10 | // Categories Database Tables 11 | 'tables' => [ 12 | 13 | 'categories' => 'categories', 14 | 'categorizables' => 'categorizables', 15 | 16 | ], 17 | 18 | // Categories Models 19 | 'models' => [ 20 | 'category' => \Rinvex\Categories\Models\Category::class, 21 | ], 22 | 23 | ]; 24 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000001_create_categories_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 22 | $table->string('slug'); 23 | $table->json('name'); 24 | $table->json('description')->nullable(); 25 | NestedSet::columns($table); 26 | $table->timestamps(); 27 | $table->softDeletes(); 28 | 29 | // Indexes 30 | $table->unique('slug'); 31 | }); 32 | } 33 | 34 | /** 35 | * Reverse the migrations. 36 | * 37 | * @return void 38 | */ 39 | public function down(): void 40 | { 41 | Schema::dropIfExists(config('rinvex.categories.tables.categories')); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000002_create_categorizables_table.php: -------------------------------------------------------------------------------- 1 | integer('category_id')->unsigned(); 21 | $table->morphs('categorizable'); 22 | $table->timestamps(); 23 | 24 | // Indexes 25 | $table->unique(['category_id', 'categorizable_id', 'categorizable_type'], 'categorizables_ids_type_unique'); 26 | $table->foreign('category_id')->references('id')->on(config('rinvex.categories.tables.categories')) 27 | ->onDelete('cascade')->onUpdate('cascade'); 28 | }); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | * 34 | * @return void 35 | */ 36 | public function down(): void 37 | { 38 | Schema::dropIfExists(config('rinvex.categories.tables.categorizables')); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | parameters: 4 | level: 5 5 | paths: 6 | - src 7 | -------------------------------------------------------------------------------- /src/Console/Commands/MigrateCommand.php: -------------------------------------------------------------------------------- 1 | alert($this->description); 35 | 36 | $path = config('rinvex.categories.autoload_migrations') ? 37 | 'vendor/rinvex/laravel-categories/database/migrations' : 38 | 'database/migrations/rinvex/laravel-categories'; 39 | 40 | if (file_exists($path)) { 41 | $this->call('migrate', [ 42 | '--step' => true, 43 | '--path' => $path, 44 | '--force' => $this->option('force'), 45 | ]); 46 | } else { 47 | $this->warn('No migrations found! Consider publish them first: php artisan rinvex:publish:categories'); 48 | } 49 | 50 | $this->line(''); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Console/Commands/PublishCommand.php: -------------------------------------------------------------------------------- 1 | alert($this->description); 35 | 36 | collect($this->option('resource') ?: ['config', 'migrations'])->each(function ($resource) { 37 | $this->call('vendor:publish', ['--tag' => "rinvex/categories::{$resource}", '--force' => $this->option('force')]); 38 | }); 39 | 40 | $this->line(''); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Console/Commands/RollbackCommand.php: -------------------------------------------------------------------------------- 1 | alert($this->description); 35 | 36 | $path = config('rinvex.categories.autoload_migrations') ? 37 | 'vendor/rinvex/laravel-categories/database/migrations' : 38 | 'database/migrations/rinvex/laravel-categories'; 39 | 40 | if (file_exists($path)) { 41 | $this->call('migrate:reset', [ 42 | '--path' => $path, 43 | '--force' => $this->option('force'), 44 | ]); 45 | } else { 46 | $this->warn('No migrations found! Consider publish them first: php artisan rinvex:publish:categories'); 47 | } 48 | 49 | $this->line(''); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Models/Category.php: -------------------------------------------------------------------------------- 1 | 'string', 73 | NestedSet::LFT => 'integer', 74 | NestedSet::RGT => 'integer', 75 | NestedSet::PARENT_ID => 'integer', 76 | 'deleted_at' => 'datetime', 77 | ]; 78 | 79 | /** 80 | * {@inheritdoc} 81 | */ 82 | protected $observables = [ 83 | 'validating', 84 | 'validated', 85 | ]; 86 | 87 | /** 88 | * The attributes that are translatable. 89 | * 90 | * @var array 91 | */ 92 | public $translatable = [ 93 | 'name', 94 | 'description', 95 | ]; 96 | 97 | /** 98 | * The default rules that the model will validate against. 99 | * 100 | * @var array 101 | */ 102 | protected $rules = []; 103 | 104 | /** 105 | * Whether the model should throw a 106 | * ValidationException if it fails validation. 107 | * 108 | * @var bool 109 | */ 110 | protected $throwValidationExceptions = true; 111 | 112 | /** 113 | * Create a new Eloquent model instance. 114 | * 115 | * @param array $attributes 116 | */ 117 | public function __construct(array $attributes = []) 118 | { 119 | $this->setTable(config('rinvex.categories.tables.categories')); 120 | $this->mergeRules([ 121 | 'name' => 'required|string|strip_tags|max:150', 122 | 'description' => 'nullable|string|max:32768', 123 | 'slug' => 'required|alpha_dash|max:150|unique:'.config('rinvex.categories.models.category').',slug', 124 | NestedSet::LFT => 'sometimes|required|integer', 125 | NestedSet::RGT => 'sometimes|required|integer', 126 | NestedSet::PARENT_ID => 'nullable|integer', 127 | ]); 128 | 129 | parent::__construct($attributes); 130 | } 131 | 132 | /** 133 | * Get all attached models of the given class to the category. 134 | * 135 | * @param string $class 136 | * 137 | * @return \Illuminate\Database\Eloquent\Relations\MorphToMany 138 | */ 139 | public function entries(string $class): MorphToMany 140 | { 141 | return $this->morphedByMany($class, 'categorizable', config('rinvex.categories.tables.categorizables'), 'category_id', 'categorizable_id', 'id', 'id'); 142 | } 143 | 144 | /** 145 | * Get the options for generating the slug. 146 | * 147 | * @return \Spatie\Sluggable\SlugOptions 148 | */ 149 | public function getSlugOptions(): SlugOptions 150 | { 151 | return SlugOptions::create() 152 | ->doNotGenerateSlugsOnUpdate() 153 | ->generateSlugsFrom('name') 154 | ->saveSlugsTo('slug'); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Providers/CategoriesServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(realpath(__DIR__.'/../../config/config.php'), 'rinvex.categories'); 37 | 38 | // Bind eloquent models to IoC container 39 | $this->registerModels([ 40 | 'rinvex.categories.category' => Category::class, 41 | ]); 42 | 43 | // Register console commands 44 | $this->commands($this->commands); 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function boot() 51 | { 52 | // Register paths to be published by the publish command. 53 | $this->publishConfigFrom(realpath(__DIR__.'/../../config/config.php'), 'rinvex/categories'); 54 | $this->publishMigrationsFrom(realpath(__DIR__.'/../../database/migrations'), 'rinvex/categories'); 55 | 56 | ! $this->app['config']['rinvex.categories.autoload_migrations'] || $this->loadMigrationsFrom(realpath(__DIR__.'/../../database/migrations')); 57 | 58 | // Map relations 59 | Relation::morphMap([ 60 | 'category' => config('rinvex.categories.models.category'), 61 | ]); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Traits/Categorizable.php: -------------------------------------------------------------------------------- 1 | morphToMany(config('rinvex.categories.models.category'), 'categorizable', config('rinvex.categories.tables.categorizables'), 'categorizable_id', 'category_id') 67 | ->withTimestamps(); 68 | } 69 | 70 | /** 71 | * Attach the given category(ies) to the model. 72 | * 73 | * @param int|string|array|\ArrayAccess|\Rinvex\Categories\Models\Category $categories 74 | * 75 | * @return void 76 | */ 77 | public function setCategoriesAttribute($categories): void 78 | { 79 | static::saved(function (self $model) use ($categories) { 80 | $model->syncCategories($categories); 81 | }); 82 | } 83 | 84 | /** 85 | * Boot the categorizable trait for the model. 86 | * 87 | * @return void 88 | */ 89 | public static function bootCategorizable() 90 | { 91 | static::deleted(function (self $model) { 92 | // Check if this is a soft delete or not by checking if `SoftDeletes::isForceDeleting` method exists 93 | (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) || $model->categories()->detach(); 94 | }); 95 | } 96 | 97 | /** 98 | * Scope query with all the given categories. 99 | * 100 | * @param \Illuminate\Database\Eloquent\Builder $builder 101 | * @param mixed $categories 102 | * 103 | * @return \Illuminate\Database\Eloquent\Builder 104 | */ 105 | public function scopeWithAllCategories(Builder $builder, $categories): Builder 106 | { 107 | $categories = $this->prepareCategoryIds($categories); 108 | 109 | collect($categories)->each(function ($category) use ($builder) { 110 | $builder->whereHas('categories', function (Builder $builder) use ($category) { 111 | return $builder->where('id', $category); 112 | }); 113 | }); 114 | 115 | return $builder; 116 | } 117 | 118 | /** 119 | * Scope query with any of the given categories. 120 | * 121 | * @param \Illuminate\Database\Eloquent\Builder $builder 122 | * @param mixed $categories 123 | * 124 | * @return \Illuminate\Database\Eloquent\Builder 125 | */ 126 | public function scopeWithAnyCategories(Builder $builder, $categories): Builder 127 | { 128 | $categories = $this->prepareCategoryIds($categories); 129 | 130 | return $builder->whereHas('categories', function (Builder $builder) use ($categories) { 131 | $builder->whereIn('id', $categories); 132 | }); 133 | } 134 | 135 | /** 136 | * Scope query with any of the given categories. 137 | * 138 | * @param \Illuminate\Database\Eloquent\Builder $builder 139 | * @param mixed $categories 140 | * 141 | * @return \Illuminate\Database\Eloquent\Builder 142 | */ 143 | public function scopeWithCategories(Builder $builder, $categories): Builder 144 | { 145 | return static::scopeWithAnyCategories($builder, $categories); 146 | } 147 | 148 | /** 149 | * Scope query without any of the given categories. 150 | * 151 | * @param \Illuminate\Database\Eloquent\Builder $builder 152 | * @param mixed $categories 153 | * 154 | * @return \Illuminate\Database\Eloquent\Builder 155 | */ 156 | public function scopeWithoutCategories(Builder $builder, $categories): Builder 157 | { 158 | $categories = $this->prepareCategoryIds($categories); 159 | 160 | return $builder->whereDoesntHave('categories', function (Builder $builder) use ($categories) { 161 | $builder->whereIn('id', $categories); 162 | }); 163 | } 164 | 165 | /** 166 | * Scope query without any categories. 167 | * 168 | * @param \Illuminate\Database\Eloquent\Builder $builder 169 | * 170 | * @return \Illuminate\Database\Eloquent\Builder 171 | */ 172 | public function scopeWithoutAnyCategories(Builder $builder): Builder 173 | { 174 | return $builder->doesntHave('categories'); 175 | } 176 | 177 | /** 178 | * Determine if the model has any of the given categories. 179 | * 180 | * @param mixed $categories 181 | * 182 | * @return bool 183 | */ 184 | public function hasCategories($categories): bool 185 | { 186 | $categories = $this->prepareCategoryIds($categories); 187 | 188 | return ! $this->categories->pluck('id')->intersect($categories)->isEmpty(); 189 | } 190 | 191 | /** 192 | * Determine if the model has any the given categories. 193 | * 194 | * @param mixed $categories 195 | * 196 | * @return bool 197 | */ 198 | public function hasAnyCategories($categories): bool 199 | { 200 | return static::hasCategories($categories); 201 | } 202 | 203 | /** 204 | * Determine if the model has all of the given categories. 205 | * 206 | * @param mixed $categories 207 | * 208 | * @return bool 209 | */ 210 | public function hasAllCategories($categories): bool 211 | { 212 | $categories = $this->prepareCategoryIds($categories); 213 | 214 | return collect($categories)->diff($this->categories->pluck('id'))->isEmpty(); 215 | } 216 | 217 | /** 218 | * Sync model categories. 219 | * 220 | * @param mixed $categories 221 | * @param bool $detaching 222 | * 223 | * @return $this 224 | */ 225 | public function syncCategories($categories, bool $detaching = true) 226 | { 227 | // Find categories 228 | $categories = $this->prepareCategoryIds($categories); 229 | 230 | // Sync model categories 231 | $this->categories()->sync($categories, $detaching); 232 | 233 | return $this; 234 | } 235 | 236 | /** 237 | * Attach model categories. 238 | * 239 | * @param mixed $categories 240 | * 241 | * @return $this 242 | */ 243 | public function attachCategories($categories) 244 | { 245 | return $this->syncCategories($categories, false); 246 | } 247 | 248 | /** 249 | * Detach model categories. 250 | * 251 | * @param mixed $categories 252 | * 253 | * @return $this 254 | */ 255 | public function detachCategories($categories = null) 256 | { 257 | $categories = ! is_null($categories) ? $this->prepareCategoryIds($categories) : null; 258 | 259 | // Sync model categories 260 | $this->categories()->detach($categories); 261 | 262 | return $this; 263 | } 264 | 265 | /** 266 | * Prepare category IDs. 267 | * 268 | * @param mixed $categories 269 | * 270 | * @return array 271 | */ 272 | protected function prepareCategoryIds($categories): array 273 | { 274 | // Convert collection to plain array 275 | if ($categories instanceof BaseCollection && is_string($categories->first())) { 276 | $categories = $categories->toArray(); 277 | } 278 | 279 | // Find categories by their ids 280 | if (is_numeric($categories) || (is_array($categories) && is_numeric(Arr::first($categories)))) { 281 | return array_map('intval', (array) $categories); 282 | } 283 | 284 | // Find categories by their slugs 285 | if (is_string($categories) || (is_array($categories) && is_string(Arr::first($categories)))) { 286 | $categories = app('rinvex.categories.category')->whereIn('slug', (array) $categories)->get()->pluck('id'); 287 | } 288 | 289 | if ($categories instanceof Model) { 290 | return [$categories->getKey()]; 291 | } 292 | 293 | if ($categories instanceof Collection) { 294 | return $categories->modelKeys(); 295 | } 296 | 297 | if ($categories instanceof BaseCollection) { 298 | return $categories->toArray(); 299 | } 300 | 301 | return (array) $categories; 302 | } 303 | } 304 | --------------------------------------------------------------------------------