├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codacy-analysis.yml │ └── test.yml ├── .gitignore ├── .php_cs ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── codecov.yml ├── composer.json ├── composer.lock ├── config └── hashid.php ├── phpunit.xml ├── src ├── Contracts │ └── Repository.php ├── Eloquent │ └── HashableId.php ├── Facade.php ├── HashIdServiceProvider.php ├── Repository.php └── Rules │ └── ExistsByHash.php └── tests ├── Models ├── BasicModel.php ├── CustomKeyModel.php ├── CustomSaltModel.php ├── HashModel.php ├── IllegalHashModel.php ├── PersistingModel.php └── PersistingModelWithCustomName.php ├── TestCase.php ├── Unit ├── HashableIdModelTest.php ├── ProviderTest.php └── RepositoryTest.php └── database └── migrations └── 0000_00_00_000000_create_hashid_test_tables.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.json] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.yml] 16 | indent_size = 2 17 | indent_style = space 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Code Snippet** 14 | Provide code snippet. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "composer" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | versioning-strategy: increase-if-necessary 8 | assignees: 9 | - "veelasky" 10 | -------------------------------------------------------------------------------- /.github/workflows/codacy-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow checks out code, performs a Codacy security scan 2 | # and integrates the results with the 3 | # GitHub Advanced Security code scanning feature. For more information on 4 | # the Codacy security scan action usage and parameters, see 5 | # https://github.com/codacy/codacy-analysis-cli-action. 6 | # For more information on Codacy Analysis CLI in general, see 7 | # https://github.com/codacy/codacy-analysis-cli. 8 | 9 | name: Codacy Security Scan 10 | 11 | on: 12 | push: 13 | branches: [ master ] 14 | pull_request: 15 | branches: [ master ] 16 | 17 | jobs: 18 | codacy-security-scan: 19 | name: Codacy Security Scan 20 | runs-on: ubuntu-latest 21 | steps: 22 | # Checkout the repository to the GitHub Actions runner 23 | - name: Checkout code 24 | uses: actions/checkout@v2 25 | 26 | # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis 27 | - name: Run Codacy Analysis CLI 28 | uses: codacy/codacy-analysis-cli-action@1.1.0 29 | with: 30 | # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository 31 | # You can also omit the token and run the tools that support default configurations 32 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 33 | verbose: true 34 | output: results.sarif 35 | format: sarif 36 | # Adjust severity of non-security issues 37 | gh-code-scanning-compat: true 38 | # Force 0 exit code to allow SARIF file generation 39 | # This will handover control about PR rejection to the GitHub side 40 | max-allowed-issues: 2147483647 41 | 42 | # Upload the SARIF file generated in the previous step 43 | - name: Upload SARIF results file 44 | uses: github/codeql-action/upload-sarif@v1 45 | with: 46 | sarif_file: results.sarif 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | phpunit: 5 | runs-on: ubuntu-20.04 6 | strategy: 7 | matrix: 8 | php: [ 8.1, 8.2, 8.3] 9 | laravel: [10.*] 10 | name: php ${{ matrix.php }} on laravel ${{ matrix.laravel }} 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v1 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: ${{ matrix.php }} 18 | tools: composer:v2 19 | coverage: xdebug 20 | extensions: bcmath, sqlite 21 | - name: Get Composer Cache Directory 22 | id: composer-cache 23 | run: | 24 | echo "::set-output name=dir::$(composer config cache-files-dir)" 25 | - uses: actions/cache@v2 26 | with: 27 | path: ${{ steps.composer-cache.outputs.dir }} 28 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ matrix.php }}-php${{ matrix.php }}-L${{ matrix.laravel }} 29 | restore-keys: | 30 | ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ matrix.php }}-php${{ matrix.php }} 31 | ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ matrix.php }} 32 | ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 33 | ${{ runner.os }}-composer- 34 | - name: Install dependencies 35 | run: | 36 | composer update --prefer-dist --no-interaction --no-suggest 37 | - name: Test 38 | run: | 39 | ./vendor/bin/phpunit 40 | - name: send coverage to codecov.io 41 | uses: codecov/codecov-action@v1 42 | with: 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | file: ./clover.xml 45 | # - name: send coverage to codacy.com 46 | # uses: codacy/codacy-coverage-reporter-action@master 47 | # with: 48 | # project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 49 | # coverage-reports: clover.xml 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | clover.xml 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | [ 5 | 'syntax' => 'short', 6 | ], 7 | 'blank_line_after_namespace' => true, 8 | 'blank_line_after_opening_tag' => true, 9 | 'blank_line_before_return' => true, 10 | 'braces' => true, 11 | 'cast_spaces' => true, 12 | 'concat_space' => ['spacing' => 'none'], 13 | 'no_multiline_whitespace_around_double_arrow' => true, 14 | 'no_empty_statement' => true, 15 | 'elseif' => true, 16 | 'simplified_null_return' => true, 17 | 'encoding' => true, 18 | 'single_blank_line_at_eof' => true, 19 | 'no_extra_consecutive_blank_lines' => [ 20 | 'use', 21 | ], 22 | 'no_spaces_after_function_name' => true, 23 | 'no_trailing_comma_in_list_call' => true, 24 | 'not_operator_with_successor_space' => true, 25 | 'function_declaration' => true, 26 | 'include' => true, 27 | 'indentation_type' => true, 28 | 'no_alias_functions' => true, 29 | 'line_ending' => true, 30 | 'lowercase_constants' => true, 31 | 'lowercase_keywords' => true, 32 | 'method_argument_space' => true, 33 | 'trailing_comma_in_multiline_array' => true, 34 | 'no_multiline_whitespace_before_semicolons' => true, 35 | 'single_import_per_statement' => true, 36 | 'no_leading_namespace_whitespace' => true, 37 | 'no_blank_lines_after_class_opening' => true, 38 | 'no_blank_lines_after_phpdoc' => true, 39 | 'object_operator_without_whitespace' => true, 40 | 'binary_operator_spaces' => [ 41 | 'align_equals' => false, 42 | ], 43 | 'no_spaces_inside_parenthesis' => true, 44 | 'phpdoc_indent' => true, 45 | 'phpdoc_inline_tag' => true, 46 | 'phpdoc_no_access' => true, 47 | 'phpdoc_no_package' => true, 48 | 'phpdoc_scalar' => true, 49 | 'phpdoc_summary' => true, 50 | 'phpdoc_to_comment' => true, 51 | 'phpdoc_no_alias_tag' => [ 52 | 'type' => 'var', 53 | ], 54 | 'self_accessor' => true, 55 | 'phpdoc_var_without_name' => true, 56 | 'no_leading_import_slash' => true, 57 | 'no_short_echo_tag' => true, 58 | 'full_opening_tag' => true, 59 | 'no_trailing_comma_in_singleline_array' => true, 60 | 'single_blank_line_before_namespace' => true, 61 | 'single_line_after_imports' => true, 62 | 'single_quote' => true, 63 | 'no_singleline_whitespace_before_semicolons' => true, 64 | 'cast_spaces' => true, 65 | 'standardize_not_equals' => true, 66 | 'ternary_operator_spaces' => true, 67 | 'no_trailing_whitespace' => true, 68 | 'unary_operator_spaces' => true, 69 | 'trim_array_spaces' => true, 70 | 'no_unused_imports' => true, 71 | 'visibility_required' => true, 72 | 'no_whitespace_in_blank_line' => true, 73 | 'ordered_imports' => [ 74 | 'sortAlgorithm' => 'length' 75 | ], 76 | 'ordered_class_elements' => [ 77 | 'order' => ['use_trait', 'constant_public', 'constant_protected', 'constant_private', 'property_public', 'property_protected', 'property_private', 'construct', 'destruct', 'magic', 'phpunit', 'method_public', 'method_protected', 'method_private'] 78 | ] 79 | ]; 80 | 81 | return PhpCsFixer\Config::create() 82 | ->setRiskyAllowed(true) 83 | ->setRules($rules) 84 | ->setFinder( 85 | PhpCsFixer\Finder::create() 86 | ->exclude('storage') 87 | ->exclude('vendor') 88 | ->notName('*.blade.php') 89 | ->in(__DIR__) 90 | ); 91 | -------------------------------------------------------------------------------- /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, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and 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 veelasky@pm.me. 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 https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rifki Alhuraibi 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 all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Laravel HashId 2 | ![Test](https://github.com/veelasky/laravel-hashid/workflows/Test/badge.svg) 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/3e929b5327a9453bb0da5cbf2ecb8794)](https://app.codacy.com/gh/veelasky/laravel-hashid?utm_source=github.com&utm_medium=referral&utm_content=veelasky/laravel-hashid&utm_campaign=Badge_Grade) 4 | [![codecov](https://codecov.io/gh/veelasky/laravel-hashid/branch/master/graph/badge.svg?token=t95ymsMyDX)](https://codecov.io/gh/veelasky/laravel-hashid) 5 | [![Latest Stable Version](https://poser.pugx.org/veelasky/laravel-hashid/v)](//packagist.org/packages/veelasky/laravel-hashid) 6 | [![StyleCI](https://github.styleci.io/repos/118424643/shield?branch=master)](https://github.styleci.io/repos/118424643?branch=master) 7 | [![Total Downloads](https://poser.pugx.org/veelasky/laravel-hashid/downloads)](//packagist.org/packages/veelasky/laravel-hashid) 8 | [![Dependents](https://poser.pugx.org/veelasky/laravel-hashid/dependents)](//packagist.org/packages/veelasky/laravel-hashid) 9 | [![License](https://poser.pugx.org/veelasky/laravel-hashid/license)](//packagist.org/packages/veelasky/laravel-hashid) 10 | 11 | Automatic HashId generator for your eloquent model. 12 | 13 | ### Version Compatibilities 14 | 15 | | Laravel HashId | PHP Version | Laravel 5.* | Laravel 6.* | Laravel 7.* | Laravel 8.* | Laravel 9.* | Laravel 10.* | 16 | |------------------|:-----------------------:|:------------------: |:------------------: |:------------------: |:------------------: |:------------------: |:------------------: | 17 | | `1.x` | `>=7.0` | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | :x: | 18 | | `2.x` | `>=7.2` - `<= 8.0` | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | 19 | | `3.0` | `>=7.4` \|\| `>= 8.0` | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | 20 | | `3.1` | `>= 8.0` | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | 21 | | `4.x` | `>= 8.1` | :x: | :x: | :x: | :x: | :x: | :white_check_mark: | 22 | 23 | ### Install 24 | 25 | ```bash 26 | composer require veelasky/laravel-hashid 27 | ``` 28 | 29 | With laravel package auto discovery, this will automatically add this package to your laravel application. 30 | 31 | ### TLDR 32 | 33 | Simply add `HashableId` trait on any of your eloquent model you are intending to use with HashId. 34 | 35 | Example: 36 | ```php 37 | use Illuminate\Database\Eloquent\Model; 38 | use Veelasky\LaravelHashId\Eloquent\HashableId; 39 | 40 | class User extends Model { 41 | use HashableId; 42 | ... 43 | } 44 | ``` 45 | 46 | ### Usage 47 | 48 | #### With Eloquent Model 49 | ```php 50 | 51 | $user = User::find(1); // instance of user. 52 | $user->hash; // generate HashId. 53 | 54 | // Database operation 55 | 56 | // get user by hashed id. 57 | $user = User::byHash($hash); 58 | 59 | // get user by hashed id, and throw ModelNotFoundException if not present. 60 | $user = User::byHashOrFail($hash); 61 | 62 | // get hashed id from the primary key. 63 | User::idToHash($id); 64 | 65 | // get ID from hashed string. 66 | User::hashToId($hash); 67 | 68 | // query scope with `byHash` method. 69 | User::query()->byHash($hash); 70 | ``` 71 | 72 | By default, all hash calculation will be calculated at runtime, but sometime you want to persist the hashed id to the database. 73 | 74 | > NOTE: when using persisting model, all database query will be check againts the table itself, except: `$model->hash` will always be calculated at runtime. 75 | ```php 76 | class User extends Model { 77 | use HashableId; 78 | 79 | // add this property to your model if you want to persist to the database. 80 | protected $shouldHashPersist = true; 81 | 82 | // by default, the persisted value will be stored in `hashid` column 83 | // override column name to your desired name. 84 | protected $hashColumnName = 'hashid'; 85 | ... 86 | } 87 | 88 | ``` 89 | 90 | #### Salt 91 | 92 | The salt is generated automatically based on your app key and hash_alphabet. If you need to use the same salt between different projects, you can set the `HASHID_SALT` environment variable. 93 | 94 | #### Route binding 95 | 96 | When HashableId trait is used, base `getRouteKey()` and `resolveRouteBinding()` are overwritten to use the HashId as route key. 97 | 98 | ```php 99 | use App\Models\User; 100 | 101 | class UserController extends Controller 102 | { 103 | /** 104 | * Route /users/{user} 105 | * Ex: GET /users/k1jTdv6l 106 | */ 107 | public function show(User $user) 108 | { 109 | ... 110 | } 111 | } 112 | ``` 113 | 114 | #### In-Depth Coverage 115 | 116 | This package use repository pattern to store all instantiated implementation of `HashId\HashId` class, this to achieve different hash result on every eloquent models defined with `HashableId` trait. 117 | 118 | ```php 119 | // using facade. 120 | HashId::hashToId($hash, User::class) // same as User::hashToId($hash); 121 | HashId::idToHash($id, User::class) // same as User::idToHash($hash); 122 | 123 | // HashId facade class is an implementation of \Veelasky\Laravel\HashId\Repository 124 | ``` 125 | 126 | However you can opt-out to not using any eloquent model or implementing your own logic to the repository. 127 | 128 | ```php 129 | HashId::make($key, $salt); // will return \HashId\HashId class. 130 | 131 | // once you instantiated the object, you can retrieve it on your next operation 132 | HashId::get($key); 133 | ``` 134 | 135 | If you're using single table inheritance model, where you want to has the same calculated hash across all inherited models, use `$hashKey` property, this will result the calculation remain the same across all inherited model. 136 | 137 | ```php 138 | class User extends Model { 139 | protected $hashKey = 'somethingUnique'; 140 | } 141 | 142 | class Customer extends User { 143 | 144 | } 145 | 146 | $customer = Customer::find(1); 147 | $user = User::find(1); 148 | 149 | $user->hash; // will be equal to $customer->hash 150 | ``` 151 | 152 | You can also specify the length and characters of the hashed Id with `HASHID_LENGTH` and `HASHID_ALPHABET` environment variable respectively, or you can publish the configuration file using this command: 153 | 154 | ```bash 155 | php artisan vendor:publish --tag=laravel-hashid-config 156 | ``` 157 | 158 | #### Extra: Validation Rules 159 | 160 | You can also use this as validation rules, simply add this rule to your validator. 161 | 162 | ```php 163 | use App\Models\User; 164 | use Veelasky\LaravelHashId\Rules\ExistsByHash; 165 | 166 | ... 167 | Validator::make([ 168 | 'id' => $hashedId 169 | ], [ 170 | 'id' => ['required', new ExistsByHash(User::class)], 171 | ]); 172 | ... 173 | ``` 174 | 175 | #### License 176 | 177 | MIT License 178 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | patch: 7 | default: 8 | informational: true 9 | comment: 10 | layout: "reach, diff, flags" 11 | require_changes: true 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "veelasky/laravel-hashid", 3 | "description": "HashId Implementation on Laravel Eloquent ORM", 4 | "keywords": [ "laravel", "lumen", "eloquent", "hashid", "hashids" ], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Rifki Alhuraibi", 9 | "email": "veelasky@pm.com" 10 | } 11 | ], 12 | "require": { 13 | "php" : "^8.1", 14 | "hashids/hashids": "^5.0", 15 | "illuminate/contracts": ">=6.18", 16 | "illuminate/config": ">=10.0", 17 | "illuminate/support": ">=10.0", 18 | "illuminate/validation": ">=10.0", 19 | "illuminate/database": ">=10.0" 20 | }, 21 | "require-dev": { 22 | "roave/security-advisories": "dev-latest", 23 | "phpunit/phpunit": ">=10.0", 24 | "orchestra/testbench": ">=8.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Veelasky\\LaravelHashId\\": "src" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Tests\\": "tests" 34 | } 35 | }, 36 | "minimum-stability": "dev", 37 | "prefer-stable": true, 38 | "extra": { 39 | "laravel" : { 40 | "providers" : [ 41 | "Veelasky\\LaravelHashId\\HashIdServiceProvider" 42 | ], 43 | "aliases" : { 44 | "HashId" : "Veelasky\\LaravelHashId\\Facade" 45 | } 46 | }, 47 | "branch-alias": { 48 | "dev-master": "4.0.x-dev" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/hashid.php: -------------------------------------------------------------------------------- 1 | env('HASHID_LENGTH', 8), 8 | 9 | /* 10 | * Determine HashId characters set. 11 | */ 12 | 'hash_alphabet' => env('HASHID_ALPHABET', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 13 | 14 | /* 15 | * Override generated HashId salt. 16 | */ 17 | 'hash_salt' => env('HASHID_SALT', null), 18 | ]; 19 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/Unit 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./src 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Contracts/Repository.php: -------------------------------------------------------------------------------- 1 | shouldHashPersist() 27 | ? $query->where($this->qualifyColumn($this->getHashColumnName()), $hash) 28 | : $query->where($this->getQualifiedKeyName(), self::hashToId($hash)); 29 | } 30 | 31 | /** 32 | * @see parent 33 | */ 34 | public function resolveRouteBinding($value, $field = null) 35 | { 36 | if ($field || is_numeric($value)) { 37 | return parent::resolveRouteBinding($value, $field); 38 | } 39 | 40 | return $this->byHash($value); 41 | } 42 | 43 | /** 44 | * Get Model by hash. 45 | * 46 | * @param $hash 47 | * 48 | * @return self|null 49 | */ 50 | public static function byHash($hash): ?self 51 | { 52 | return self::query()->byHash($hash)->first(); 53 | } 54 | 55 | /** 56 | * Get model by hash or fail. 57 | * 58 | * @param $hash 59 | * 60 | * @return self 61 | * 62 | * @throw \Illuminate\Database\Eloquent\ModelNotFoundException 63 | */ 64 | public static function byHashOrFail($hash): self 65 | { 66 | return self::query()->byHash($hash)->firstOrFail(); 67 | } 68 | 69 | /** 70 | * Get Hash Attribute. 71 | * 72 | * @return string|null 73 | */ 74 | public function getHashAttribute(): ?string 75 | { 76 | return $this->exists 77 | ? $this->getHashIdRepository()->idToHash($this->getKey(), $this->getHashKey()) 78 | : null; 79 | } 80 | 81 | /** 82 | * Decode Hash to ID for the model. 83 | * 84 | * @param string $hash 85 | * 86 | * @return int|null 87 | */ 88 | public static function hashToId(string $hash): ?int 89 | { 90 | return (new static()) 91 | ->getHashIdRepository() 92 | ->hashToId($hash, (new static())->getHashKey()); 93 | } 94 | 95 | /** 96 | * Get Hash Key. 97 | * 98 | * @return string 99 | */ 100 | public function getHashKey(): string 101 | { 102 | return property_exists($this, 'hashKey') 103 | ? $this->hashKey 104 | : static::class; 105 | } 106 | 107 | /** 108 | * Encode Id to Hash for the model. 109 | * 110 | * @param int $primaryKey 111 | * 112 | * @return string 113 | */ 114 | public static function idToHash(int $primaryKey): string 115 | { 116 | return (new static()) 117 | ->getHashIdRepository() 118 | ->idToHash($primaryKey, (new static())->getHashKey()); 119 | } 120 | 121 | /** 122 | * Determine if hash should persist in database. 123 | * 124 | * @return bool 125 | */ 126 | public function shouldHashPersist(): bool 127 | { 128 | return property_exists($this, 'shouldHashPersist') 129 | ? $this->shouldHashPersist 130 | : false; 131 | } 132 | 133 | /** 134 | * Get HashId column name. 135 | * 136 | * @return string 137 | */ 138 | public function getHashColumnName(): string 139 | { 140 | return property_exists($this, 'hashColumnName') 141 | ? $this->hashColumnName 142 | : 'hashid'; 143 | } 144 | 145 | /** 146 | * register boot trait method. 147 | * 148 | * @return void 149 | */ 150 | public static function bootHashableId() 151 | { 152 | self::created(function ($model) { 153 | if ($model->shouldHashPersist()) { 154 | $model->{$model->getHashColumnName()} = self::idToHash($model->getKey()); 155 | 156 | $model->save(); 157 | } 158 | }); 159 | } 160 | 161 | /** 162 | * Get HashId Repository. 163 | * 164 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 165 | * 166 | * @return \Veelasky\LaravelHashId\Repository 167 | */ 168 | protected function getHashIdRepository(): Repository 169 | { 170 | if ($this->getKeyType() !== 'int') { 171 | throw new LogicException('Invalid implementation of HashId, only works with `int` value of `keyType`'); 172 | } 173 | 174 | // get custom salt for the model (if exists) 175 | if (method_exists($this, 'getHashIdSalt')) { 176 | // force the repository to make a new instance of hashid. 177 | app('app.hashid')->make($this->getHashKey(), $this->getHashIdSalt()); 178 | } 179 | 180 | return app('app.hashid'); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Facade.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 18 | $this->publishes([ 19 | __DIR__.'/../config' => $this->app->basePath('config'), 20 | ], 'laravel-hashid-config'); 21 | } 22 | } 23 | 24 | /** 25 | * Register the service provider. 26 | */ 27 | public function register() 28 | { 29 | $this->mergeConfigFrom( 30 | __DIR__.'/../config/hashid.php', 31 | 'hashid' 32 | ); 33 | 34 | $this->app->singleton('app.hashid', function () { 35 | return new Repository(); 36 | }); 37 | $this->app->alias('app.hashid', Repository::class); 38 | $this->app->alias('app.hashid', RepositoryContract::class); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Repository.php: -------------------------------------------------------------------------------- 1 | hashes; 22 | } 23 | 24 | /** {@inheritdoc} */ 25 | public function hashToId(string $hash, string $key = 'default'): ?int 26 | { 27 | $result = $this->get($key)->decode($hash); 28 | 29 | return $result[0] ?? null; 30 | } 31 | 32 | /** {@inheritdoc} */ 33 | public function idToHash(int $idKey, string $key = 'default'): string 34 | { 35 | return $this->get($key)->encode($idKey); 36 | } 37 | 38 | /** {@inheritdoc} */ 39 | public function make(string $key, string $salt): Hashids 40 | { 41 | $hashids = new Hashids($salt, config('hashid.hash_length'), config('hashid.hash_alphabet')); 42 | $this->set($key, $hashids); 43 | 44 | return $hashids; 45 | } 46 | 47 | /** {@inheritdoc} */ 48 | public function set(string $key, Hashids $value): RepositoryContract 49 | { 50 | $this->hashes[$key] = $value; 51 | 52 | return $this; 53 | } 54 | 55 | /** {@inheritdoc} */ 56 | public function get(string $key): Hashids 57 | { 58 | if ($this->has($key)) { 59 | return $this->hashes[$key]; 60 | } 61 | 62 | if ($key === 'default') { 63 | return $this->make( 64 | $key, 65 | config('hashid.hash_salt') ? config('hashid.hash_salt') : substr(config('app.key', config('hashid.hash_alphabet')), 8, 4).substr(config('app.key', 'lara'), -4) 66 | ); 67 | } 68 | 69 | $key = strlen($key) > 4 ? $key : 'default'.$key; 70 | 71 | return $this->make($key, config('hashid.hash_salt') ? config('hashid.hash_salt') : substr($key, -4).substr(config('app.key', 'lara'), -4)); 72 | } 73 | 74 | /** {@inheritdoc} */ 75 | public function has(string $key): bool 76 | { 77 | return array_key_exists($key, $this->hashes); 78 | } 79 | 80 | /** {@inheritdoc} */ 81 | public function offsetExists($offset): bool 82 | { 83 | return $this->has($offset); 84 | } 85 | 86 | /** {@inheritdoc} */ 87 | public function offsetGet($offset): Hashids 88 | { 89 | return $this->get($offset); 90 | } 91 | 92 | /** {@inheritdoc} */ 93 | public function offsetSet(mixed $offset, mixed $value): void 94 | { 95 | $this->set($offset, $value); 96 | } 97 | 98 | /** {@inheritdoc} */ 99 | public function offsetUnset(mixed $offset): void 100 | { 101 | unset($this->hashes[$offset]); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Rules/ExistsByHash.php: -------------------------------------------------------------------------------- 1 | $class 22 | */ 23 | public function __construct(string $class) 24 | { 25 | $this->model = new $class(); 26 | 27 | if (!method_exists($this->model, 'bootHashableId')) { 28 | throw new InvalidArgumentException('Class does not use HashableId'); 29 | } 30 | 31 | parent::__construct($class, $this->model->shouldHashPersist() ? $this->model->getHashColumnName() : $this->model->getKeyName()); 32 | } 33 | 34 | public function validate(string $attribute, mixed $value, Closure $fail): void 35 | { 36 | if (!$value || (!$this->model->shouldHashPersist() && !$value = $this->model::hashToId($value))) { 37 | $this->fail($attribute, $fail); 38 | 39 | return; 40 | } 41 | 42 | $validator = validator( 43 | [$attribute => $value], 44 | [$attribute => $this->buildParentRule()] 45 | ); 46 | 47 | if ($validator->fails()) { 48 | $this->fail($attribute, $fail); 49 | } 50 | } 51 | 52 | public function setValidator($validator): static 53 | { 54 | $this->validator = $validator; 55 | 56 | return $this; 57 | } 58 | 59 | protected function buildParentRule(): Exists 60 | { 61 | return tap(new parent($this->table, $this->column), function (Exists $parent) { 62 | $parent->wheres = $this->wheres; 63 | $parent->using = $this->using; 64 | }); 65 | } 66 | 67 | protected function fail(string $attribute, Closure $fail): void 68 | { 69 | $fail($this->validator->customMessages["{$attribute}.existsByHash"] ?? 'validation.exists') 70 | ->translate([ 71 | 'attribute' => $this->validator->getDisplayableAttribute($attribute), 72 | ]); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Models/BasicModel.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/database/migrations'); 17 | } 18 | 19 | /** {@inheritdoc} */ 20 | protected function getEnvironmentSetUp($app) 21 | { 22 | $app['config']->set('database.default', 'testbench'); 23 | $app['config']->set('database.connections.testbench', [ 24 | 'driver' => 'sqlite', 25 | 'database' => ':memory:', 26 | 'prefix' => '', 27 | ]); 28 | } 29 | 30 | /** {@inheritdoc} */ 31 | protected function getPackageProviders($app) 32 | { 33 | return [HashIdServiceProvider::class]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Unit/HashableIdModelTest.php: -------------------------------------------------------------------------------- 1 | expectExceptionMessage('Invalid implementation of HashId, only works with `int` value of `keyType`'); 29 | IllegalHashModel::idToHash(1); 30 | } 31 | 32 | public function test_hash_model() 33 | { 34 | $randomId = rand(1, 1000); 35 | $hashedId = HashModel::idToHash($randomId); 36 | 37 | // assert using model. 38 | $this->assertEquals($randomId, HashModel::hashToId($hashedId)); 39 | 40 | // assert using repo. 41 | $this->assertEquals($randomId, $this->getRepository()->hashToId($hashedId, HashModel::class)); 42 | $this->assertEquals($hashedId, $this->getRepository()->idToHash($randomId, HashModel::class)); 43 | } 44 | 45 | public function test_model_not_persisting() 46 | { 47 | $m = new HashModel(); 48 | $m->save(); 49 | 50 | $this->assertDatabaseMissing($m->getTable(), [ 51 | 'hashid' => HashModel::idToHash($m->id), 52 | ]); 53 | $this->assertDatabaseHas($m->getTable(), [ 54 | 'id' => $m->id, 55 | ]); 56 | 57 | $this->assertEquals(HashModel::idToHash($m->getKey()), $m->hash); 58 | 59 | $t = HashModel::byHash($m->hash); 60 | $this->assertEquals($t->id, $m->id); 61 | 62 | $this->expectException(ModelNotFoundException::class); 63 | HashModel::byHashOrFail(Str::random(8)); 64 | } 65 | 66 | public function test_model_persistence() 67 | { 68 | $m = new PersistingModel(); 69 | $m->save(); 70 | 71 | $this->assertDatabaseHas($m->getTable(), [ 72 | 'id' => $m->id, 73 | 'hashid' => $m->hash, 74 | ]); 75 | 76 | $this->assertDatabaseMissing($m->getTable(), [ 77 | 'id' => $m->id, 78 | 'custom_name' => $m->hash, 79 | ]); 80 | 81 | $this->assertEquals($m->hashid, $m->hash); 82 | 83 | $t = PersistingModel::byHash($m->hash); 84 | $this->assertEquals($t->id, $m->id); 85 | 86 | $this->expectException(ModelNotFoundException::class); 87 | PersistingModel::byHashOrFail(Str::random(8)); 88 | } 89 | 90 | public function test_model_persistence_with_column_name() 91 | { 92 | $m = new PersistingModelWithCustomName(); 93 | $m->save(); 94 | 95 | $this->assertDatabaseHas($m->getTable(), [ 96 | 'id' => $m->id, 97 | 'custom_name' => $m->hash, 98 | ]); 99 | 100 | $this->assertDatabaseMissing($m->getTable(), [ 101 | 'id' => $m->id, 102 | 'hashid' => $m->hash, 103 | ]); 104 | 105 | $this->assertEquals($m->custom_name, $m->hash); 106 | 107 | $t = PersistingModelWithCustomName::byHash($m->hash); 108 | $this->assertEquals($t->id, $m->id); 109 | 110 | $this->expectException(ModelNotFoundException::class); 111 | PersistingModelWithCustomName::byHashOrFail(Str::random(8)); 112 | } 113 | 114 | public function test_validation_rules() 115 | { 116 | $m = new HashModel(); 117 | $m->save(); 118 | 119 | $this->assertDatabaseHas($m->getTable(), [ 120 | 'id' => $m->id, 121 | ]); 122 | 123 | $validator = Validator::make([ 124 | 'id' => $m->hash, 125 | ], [ 126 | 'id' => [new ExistsByHash(HashModel::class)], 127 | ]); 128 | $this->assertFalse($validator->fails()); 129 | 130 | $validator = Validator::make([ 131 | 'id' => Str::random(), 132 | ], [ 133 | 'id' => [new ExistsByHash(HashModel::class)], 134 | ]); 135 | $this->assertTrue($validator->fails()); 136 | 137 | $this->expectException(ValidationException::class); 138 | $validator->validate(); 139 | } 140 | 141 | public function test_validation_not_using_trait() 142 | { 143 | $this->expectException(InvalidArgumentException::class); 144 | 145 | Validator::make([ 146 | 'id' => Str::random(), 147 | ], [ 148 | 'id' => [new ExistsByHash(BasicModel::class)], 149 | ]); 150 | } 151 | 152 | public function test_validation_on_persisting_model() 153 | { 154 | $m = new PersistingModel(); 155 | $m->save(); 156 | 157 | $validator = Validator::make([ 158 | $m->getHashColumnName() => $m->hash, 159 | ], [ 160 | $m->getHashColumnName() => [new ExistsByHash(PersistingModel::class)], 161 | ]); 162 | $this->assertFalse($validator->fails()); 163 | 164 | $validator = Validator::make([ 165 | $m->getHashColumnName() => Str::random(), 166 | ], [ 167 | $m->getHashColumnName() => [new ExistsByHash(PersistingModel::class)], 168 | ]); 169 | $this->assertTrue($validator->fails()); 170 | 171 | $this->expectException(ValidationException::class); 172 | $validator->validate(); 173 | } 174 | 175 | public function test_custom_key_model() 176 | { 177 | $m = new CustomKeyModel(); 178 | $m->save(); 179 | 180 | $this->assertEquals('somethingUnique', $m->getHashKey()); 181 | $this->assertEquals(CustomKeyModel::idToHash($m->getKey()), $m->hash); 182 | } 183 | 184 | public function test_custom_salt_model() 185 | { 186 | $this->getRepository()->make('custom', (new CustomSaltModel())->getHashIdSalt()); 187 | $this->assertEquals(CustomSaltModel::idToHash(1), $this->getRepository()->idToHash(1, 'custom')); 188 | } 189 | 190 | public function test_route_binding() 191 | { 192 | $m = new HashModel(); 193 | $m->save(); 194 | 195 | $resolved = $m->resolveRouteBinding($m->hash); 196 | $this->assertTrue($resolved->is($m)); 197 | 198 | $resolved = $m->resolveRouteBinding($m->getKey()); 199 | $this->assertTrue($resolved->is($m)); 200 | } 201 | 202 | protected function getRepository(): Repository 203 | { 204 | return app('app.hashid'); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/Unit/ProviderTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Repository::class, app('app.hashid')); 14 | $this->assertInstanceOf(Repository::class, app(Repository::class)); 15 | $this->assertInstanceOf(Repository::class, app(\Veelasky\LaravelHashId\Contracts\Repository::class)); 16 | } 17 | 18 | public function test_provider_load_config_files() 19 | { 20 | $this->assertEquals(8, config('hashid.hash_length')); 21 | $this->assertEquals('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', config('hashid.hash_alphabet')); 22 | } 23 | 24 | public function test_facade() 25 | { 26 | $this->assertEquals(Facade::get('default'), app('app.hashid')->get('default')); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Unit/RepositoryTest.php: -------------------------------------------------------------------------------- 1 | getRepository()->idToHash($randomId, $key); 17 | 18 | $this->assertInstanceOf(Hashids::class, $this->getRepository()->get($key)); 19 | $this->assertArrayHasKey($key, $this->getRepository()->all()); 20 | 21 | // assert result 22 | $this->assertIsString($hashedId); 23 | $this->assertEquals($randomId, $this->getRepository()->hashToId($hashedId, $key)); 24 | } 25 | 26 | public function test_result_unchanged() 27 | { 28 | $key = Str::random(); 29 | $hashId = $this->getRepository()->get($key); 30 | 31 | $this->assertEquals($hashId, $this->getRepository()->get($key)); 32 | $this->assertEquals($hashId->encode(666), $this->getRepository()->idToHash(666, $key)); 33 | } 34 | 35 | public function test_array_access() 36 | { 37 | $this->assertIsArray($this->getRepository()->all()); 38 | 39 | $key = Str::random(); 40 | $hashid = $this->getRepository()[$key]; 41 | 42 | $this->assertEquals($hashid, $this->getRepository()->get($key)); 43 | 44 | $this->getRepository()->offsetSet($key, $hashid); 45 | $this->assertEquals($hashid, $this->getRepository()->get($key)); 46 | 47 | $this->assertArrayHasKey($key, $this->getRepository()); 48 | 49 | unset($this->getRepository()[$key]); 50 | $this->assertArrayNotHasKey($key, $this->getRepository()); 51 | } 52 | 53 | public function test_should_have_default() 54 | { 55 | $default = $this->getRepository()->get('default'); 56 | 57 | $this->assertInstanceOf(Hashids::class, $default); 58 | 59 | $this->assertEquals([1234], $default->decode('jXrJE9PL')); 60 | } 61 | 62 | protected function getRepository(): Repository 63 | { 64 | return app('app.hashid'); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/database/migrations/0000_00_00_000000_create_hashid_test_tables.php: -------------------------------------------------------------------------------- 1 | id(); 19 | $table->string('hashid')->nullable(); 20 | $table->string('custom_name')->nullable(); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('hashid_test'); 33 | } 34 | } 35 | --------------------------------------------------------------------------------