├── .codeclimate.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── coverage.yml │ ├── php-cs-fixer.yml │ ├── phpunit.yml │ └── update-changelog.yml ├── .gitignore ├── .idea ├── .gitignore ├── blade.xml ├── hooks.iml ├── inspectionProfiles │ └── Project_Default.xml ├── laravel-idea.xml ├── modules.xml ├── php-test-framework.xml ├── php.xml ├── phpunit.xml └── vcs.xml ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── pre-commit.sh ├── composer.json ├── config └── hooks.php ├── ide.json ├── phpunit.xml ├── src ├── Context.php ├── Hook.php ├── Hookable.php ├── Hooks.php ├── Support │ ├── Facades │ │ ├── .gitkeep │ │ └── Hook.php │ ├── HookRegistry.php │ └── HooksServiceProvider.php └── View │ ├── Components │ └── Hook.php │ └── Observer.php ├── tests ├── HookableTest.php ├── TestCase.php ├── helpers.php └── views │ ├── demo.blade.php │ └── hello.blade.php └── whisky.json /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | exclude_patterns: 2 | - ".github/" 3 | - ".idea/" 4 | - "stubs/" 5 | - "tests/" 6 | - "**/vendor/" 7 | - "**/node_modules/" 8 | - "*.md" 9 | - ".*.yml" 10 | - "LICENSE" 11 | - "composer.json" 12 | - "phpunit.xml" 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **What version does this affect?** 14 | - Laravel Version: [e.g. 10.0.0] 15 | - Package Version: [e.g. 1.5.0] 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature idea or improvement 4 | title: '' 5 | labels: enhancement 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/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | coverage: 10 | runs-on: ubuntu-latest 11 | 12 | name: Publish code coverage 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: 8.3 22 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 23 | coverage: pcov 24 | 25 | - name: Get composer cache directory 26 | id: composer-cache 27 | run: | 28 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 29 | 30 | - name: Cache dependencies 31 | uses: actions/cache@v4 32 | with: 33 | path: ${{ steps.composer-cache.outputs.dir }} 34 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 35 | restore-keys: | 36 | ${{ runner.os }}-composer- 37 | 38 | - name: Install dependencies 39 | env: 40 | COMPOSER_DISCARD_CHANGES: true 41 | run: composer require --no-progress --no-interaction --prefer-dist --update-with-all-dependencies "laravel/framework:^11.0" 42 | 43 | - name: Run and publish code coverage 44 | uses: paambaati/codeclimate-action@v9.0 45 | env: 46 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 47 | with: 48 | coverageCommand: vendor/bin/phpunit --coverage-clover ${{ github.workspace }}/clover.xml 49 | coverageLocations: 50 | "${{github.workspace}}/clover.xml:clover" 51 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | 13 | name: Run code style checks 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: 8.3 23 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 24 | coverage: none 25 | 26 | - name: Get composer cache directory 27 | id: composer-cache 28 | run: | 29 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 30 | 31 | - name: Cache dependencies 32 | uses: actions/cache@v4 33 | with: 34 | path: ${{ steps.composer-cache.outputs.dir }} 35 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-composer- 38 | 39 | - name: Install dependencies 40 | env: 41 | COMPOSER_DISCARD_CHANGES: true 42 | run: composer require --no-progress --no-interaction --prefer-dist --update-with-all-dependencies "laravel/framework:^11.0" 43 | 44 | - name: Run PHP CS Fixer 45 | run: ./vendor/bin/php-cs-fixer fix --diff --dry-run 46 | -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | schedule: 9 | - cron: '0 14 * * 3' # Run Wednesdays at 2pm EST 10 | 11 | jobs: 12 | php-tests: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | php: [ 8.2, 8.3, 8.4 ] 18 | laravel: [ 10.*, 11.*, 12.* ] 19 | dependency-version: [ stable, lowest ] 20 | 21 | timeout-minutes: 10 22 | 23 | name: "${{ matrix.php }} / ${{ matrix.laravel }} (${{ matrix.dependency-version }})" 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | 29 | - name: Setup PHP 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php }} 33 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 34 | tools: composer:v2 35 | 36 | - name: Register composer cache directory 37 | id: composer-cache 38 | run: | 39 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 40 | 41 | - name: Cache dependencies 42 | uses: actions/cache@v4 43 | with: 44 | path: ${{ steps.composer-cache.outputs.dir }} 45 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 46 | restore-keys: | 47 | ${{ runner.os }}-composer- 48 | 49 | - name: Install dependencies 50 | run: | 51 | composer require --no-interaction --prefer-dist --prefer-${{ matrix.dependency-version }} --update-with-all-dependencies "laravel/framework:${{ matrix.laravel }}" 52 | 53 | - name: Execute tests 54 | run: vendor/bin/phpunit 55 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: Update Changelog 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | update-publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | repository: ${{ github.event.repository.full_name }} 14 | ref: 'main' 15 | 16 | - name: Update changelog 17 | uses: thomaseizinger/keep-a-changelog-new-release@v1 18 | with: 19 | version: ${{ github.event.release.tag_name }} 20 | 21 | - name: Commit changelog back to repo 22 | uses: EndBug/add-and-commit@v8 23 | with: 24 | add: 'CHANGELOG.md' 25 | message: ${{ github.event.release.tag_name }} 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.phar 3 | composer.lock 4 | .phpunit.result.cache 5 | .php_cs.cache 6 | .php-cs-fixer.cache 7 | 8 | .DS_Store 9 | .phpstorm.meta.php 10 | _ide_helper.php 11 | 12 | node_modules 13 | mix-manifest.json 14 | yarn-error.log 15 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | 10 | laravel-idea-personal.xml -------------------------------------------------------------------------------- /.idea/blade.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /.idea/hooks.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 128 | -------------------------------------------------------------------------------- /.idea/laravel-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/php-test-framework.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 161 | 162 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 172 | -------------------------------------------------------------------------------- /.idea/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 7 | ->setIndent("\t") 8 | ->setLineEnding("\n") 9 | ->setRules([ 10 | '@PSR2' => true, 11 | 'function_declaration' => [ 12 | 'closure_function_spacing' => 'none', 13 | 'closure_fn_spacing' => 'none', 14 | ], 15 | 'ordered_imports' => [ 16 | 'sort_algorithm' => 'alpha', 17 | ], 18 | 'array_indentation' => true, 19 | 'braces' => [ 20 | 'allow_single_line_closure' => true, 21 | ], 22 | 'no_break_comment' => false, 23 | 'return_type_declaration' => [ 24 | 'space_before' => 'none', 25 | ], 26 | 'blank_line_after_opening_tag' => true, 27 | 'compact_nullable_typehint' => true, 28 | 'cast_spaces' => true, 29 | 'concat_space' => [ 30 | 'spacing' => 'none', 31 | ], 32 | 'declare_equal_normalize' => [ 33 | 'space' => 'none', 34 | ], 35 | 'function_typehint_space' => true, 36 | 'new_with_braces' => true, 37 | 'method_argument_space' => true, 38 | 'no_empty_statement' => true, 39 | 'no_empty_comment' => true, 40 | 'no_empty_phpdoc' => true, 41 | 'no_extra_blank_lines' => [ 42 | 'tokens' => [ 43 | 'extra', 44 | 'use', 45 | 'use_trait', 46 | 'return', 47 | ], 48 | ], 49 | 'no_leading_import_slash' => true, 50 | 'no_leading_namespace_whitespace' => true, 51 | 'no_blank_lines_after_class_opening' => true, 52 | 'no_blank_lines_after_phpdoc' => true, 53 | 'no_whitespace_in_blank_line' => false, 54 | 'no_whitespace_before_comma_in_array' => true, 55 | 'no_useless_else' => true, 56 | 'no_useless_return' => true, 57 | 'single_trait_insert_per_statement' => true, 58 | 'psr_autoloading' => true, 59 | 'dir_constant' => true, 60 | 'single_line_comment_style' => [ 61 | 'comment_types' => ['hash'], 62 | ], 63 | 'include' => true, 64 | 'is_null' => true, 65 | 'linebreak_after_opening_tag' => true, 66 | 'lowercase_cast' => true, 67 | 'lowercase_static_reference' => true, 68 | 'magic_constant_casing' => true, 69 | 'magic_method_casing' => true, 70 | 'class_attributes_separation' => [ 71 | // TODO: This can be reverted when https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5869 is merged 72 | 'elements' => ['const' => 'one', 'method' => 'one', 'property' => 'one'], 73 | ], 74 | 'modernize_types_casting' => true, 75 | 'native_function_casing' => true, 76 | 'native_function_type_declaration_casing' => true, 77 | 'no_alias_functions' => true, 78 | 'no_multiline_whitespace_around_double_arrow' => true, 79 | 'multiline_whitespace_before_semicolons' => true, 80 | 'no_short_bool_cast' => true, 81 | 'no_unused_imports' => true, 82 | 'no_php4_constructor' => true, 83 | 'no_singleline_whitespace_before_semicolons' => true, 84 | 'no_spaces_around_offset' => true, 85 | 'no_trailing_comma_in_list_call' => true, 86 | 'no_trailing_comma_in_singleline_array' => true, 87 | 'normalize_index_brace' => true, 88 | 'object_operator_without_whitespace' => true, 89 | 'phpdoc_annotation_without_dot' => true, 90 | 'phpdoc_indent' => true, 91 | 'phpdoc_no_package' => true, 92 | 'phpdoc_no_access' => true, 93 | 'phpdoc_no_useless_inheritdoc' => true, 94 | 'phpdoc_single_line_var_spacing' => true, 95 | 'phpdoc_trim' => true, 96 | 'phpdoc_types' => true, 97 | 'semicolon_after_instruction' => true, 98 | 'array_syntax' => [ 99 | 'syntax' => 'short', 100 | ], 101 | 'list_syntax' => [ 102 | 'syntax' => 'short', 103 | ], 104 | 'short_scalar_cast' => true, 105 | 'single_blank_line_before_namespace' => true, 106 | 'single_quote' => true, 107 | 'standardize_not_equals' => true, 108 | 'ternary_operator_spaces' => true, 109 | 'whitespace_after_comma_in_array' => true, 110 | 'not_operator_with_successor_space' => true, 111 | 'trailing_comma_in_multiline' => true, 112 | 'trim_array_spaces' => true, 113 | 'binary_operator_spaces' => true, 114 | 'unary_operator_spaces' => true, 115 | 'php_unit_method_casing' => [ 116 | 'case' => 'snake_case', 117 | ], 118 | 'php_unit_test_annotation' => [ 119 | 'style' => 'prefix', 120 | ], 121 | ]) 122 | ->setFinder( 123 | PhpCsFixer\Finder::create() 124 | ->exclude('.circleci') 125 | ->exclude('bin') 126 | ->exclude('node_modules') 127 | ->exclude('vendor') 128 | ->notPath('.phpstorm.meta.php') 129 | ->notPath('_ide_helper.php') 130 | ->notPath('artisan') 131 | ->in(__DIR__) 132 | ); 133 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes will be documented in this file following the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 4 | format. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | 8 | ## [0.6.1] - 2025-04-29 9 | 10 | ## [0.6.0] - 2025-03-04 11 | 12 | ## [0.5.0] - 2024-03-13 13 | 14 | ### Changed 15 | 16 | - You can now pass a slot to `` to set the fallback content 17 | 18 | ## [0.4.0] - 2024-03-13 19 | 20 | ### Changed 21 | 22 | - Updated `HookRegistry` to be scoped rather than a singleton, so that the registry resets each request 23 | 24 | ## [0.3.0] - 2024-03-12 25 | 26 | ## [0.2.0] - 2024-02-12 27 | 28 | ### Added 29 | 30 | - Added support for view hooks as closures that return a view 31 | - Added support for explicitly setting the view name in `` 32 | 33 | ## [0.1.0] - 2024-01-24 34 | 35 | ### Changed 36 | 37 | - Added `Context` and moved `stopPropagation()` to it 38 | - Refactored how hooks are registered in the `HookRegistry` 39 | 40 | ## [0.0.4] - 2024-01-22 41 | 42 | ### Changed 43 | 44 | - We now filter out results that are `null` 45 | 46 | ## [0.0.3] - 2024-01-21 47 | 48 | ### Added 49 | 50 | - Made hook calls fluent 51 | 52 | ## [0.0.2] - 2024-01-21 53 | 54 | ### Changed 55 | 56 | - Renamed `Breakpoints` to `Hooks` 57 | - Removed unused global `hook()` helper 58 | 59 | ## [0.0.1] - 2024-01-21 60 | 61 | ### Added 62 | 63 | - Initial release 64 | 65 | ## [0.0.1] 66 | 67 | # Keep a Changelog Syntax 68 | 69 | - `Added` for new features. 70 | - `Changed` for changes in existing functionality. 71 | - `Deprecated` for soon-to-be removed features. 72 | - `Removed` for now removed features. 73 | - `Fixed` for any bug fixes. 74 | - `Security` in case of vulnerabilities. 75 | 76 | [Unreleased]: https://github.com/glhd/hooks/compare/0.6.1...HEAD 77 | 78 | [0.6.1]: https://github.com/glhd/hooks/compare/0.6.0...0.6.1 79 | 80 | [0.6.0]: https://github.com/glhd/hooks/compare/0.5.0...0.6.0 81 | 82 | [0.5.0]: https://github.com/glhd/hooks/compare/0.4.0...0.5.0 83 | 84 | [0.4.0]: https://github.com/glhd/hooks/compare/0.3.0...0.4.0 85 | 86 | [0.3.0]: https://github.com/glhd/hooks/compare/0.2.0...0.3.0 87 | 88 | [0.2.0]: https://github.com/glhd/hooks/compare/0.1.0...0.2.0 89 | 90 | [0.1.0]: https://github.com/glhd/hooks/compare/0.0.4...0.1.0 91 | 92 | [0.0.4]: https://github.com/glhd/hooks/compare/0.0.3...0.0.4 93 | 94 | [0.0.3]: https://github.com/glhd/hooks/compare/0.0.2...0.0.3 95 | 96 | [0.0.2]: https://github.com/glhd/hooks/compare/0.0.1...0.0.2 97 | 98 | [0.0.1]: https://github.com/glhd/hooks/compare/0.0.1...0.0.1 99 | 100 | [0.0.1]: https://github.com/glhd/hooks/compare/0.0.1...0.0.1 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Galahad, Inc. 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 |
2 | 3 | Build Status 7 | 8 | 9 | Coverage Status 13 | 14 | 15 | Latest Stable Release 19 | 20 | 21 | MIT Licensed 25 | 26 | 27 | Follow @inxilpro on Twitter 31 | 32 |
33 | 34 | # Hooks 35 | 36 | ## Installation 37 | 38 | ```shell 39 | composer require glhd/hooks 40 | ``` 41 | 42 | ## Usage 43 | 44 | The hooks package provides two types of hooks: hooking into class execution, and hooking into view rendering. 45 | 46 | ### Within Classes 47 | 48 | To make a class "hook-able" you need to use the `Hookable` trait. In your code, you can add `callHook()` 49 | calls anywhere that you want to allow outside code to execute. For example, if you were implementing 50 | a `Session` class, you might want to allow code to hook into before the session starts, and before 51 | the session saves: 52 | 53 | ```php 54 | use Glhd\Hooks\Hookable; 55 | 56 | class MySessionClass implements SessionHandlerInterface 57 | { 58 | use Hookable; 59 | 60 | public function public open(string $path, string $name): bool 61 | { 62 | $this->callHook('beforeOpened', $name); 63 | // ... 64 | } 65 | 66 | public function write(string $id, string $data): bool 67 | { 68 | $this->callHook('beforeWritten'); 69 | // .. 70 | } 71 | } 72 | ``` 73 | 74 | Now, you can hook into these points from elsewhere in your app: 75 | 76 | ```php 77 | // Get all the available hook points 78 | $hooks = Session::hook(); 79 | 80 | // Register your custom code to execute at those points 81 | $hooks->beforeOpened(function($name) { 82 | Log::info("Starting session '$name'"); 83 | }); 84 | 85 | $hooks->beforeWritten(function() { 86 | Log::info('Writing session to storage'); 87 | }); 88 | ``` 89 | 90 | Now, whenever `MySessionClass::open` is called, a `"Starting session ''"` message will be logged, 91 | and whenever `MySessionClass::write` is called, a `"Writing session to storage"` message will be logged. 92 | 93 | ### Hook Priority 94 | 95 | You can pass an additional `int` priority to your hooks, to account for multiple hooks 96 | attached to the same point. For example: 97 | 98 | ```php 99 | $hooks->beforeOpened(fn($name) => Log::info('Registered First'), 500); 100 | $hooks->beforeOpened(fn($name) => Log::info('Registered Second'), 100); 101 | ``` 102 | 103 | Would cause "Registered Second" to log before "Registered First". If you don't pass a priority, the 104 | default of `1000` will be used. All hooks at the same priority will be executed in the order they 105 | were registered. 106 | 107 | ### Stopping Propagation 108 | 109 | Hooks can halt further hooks from running with a special `stopPropagation` call (just like JavaScript). 110 | All hooks receive a `Context` object as the last argument. Calling `stopPropagation` on this object 111 | will halt any future hooks from running: 112 | 113 | ```php 114 | use Glhd\Hooks\Context; 115 | 116 | $hooks->beforeOpened(function($name) { 117 | Log::info('Lower-priority hook'); 118 | }, 500); 119 | 120 | $hooks->beforeOpened(function($name, Context $context) { 121 | Log::info('Higher-priority hook'); 122 | $context->stopPropagation(); 123 | }, 100); 124 | ``` 125 | 126 | In the above case, the `'Lower-priority hook'` message will never be logged, because a higher-priority 127 | hook stopped propagation before it could run. 128 | 129 | ### Passing data between your code and hooks 130 | 131 | There are three different ways that data gets passed in and out of hooks: 132 | 133 | 1. Passing arguments *into* hooks (one-way) 134 | 2. Returning values *from* hooks (one-way) 135 | 3. Passing data into hooks that can be mutated by hooks (two-way) 136 | 137 | #### One-way data 138 | 139 | Options 1 and 2 are relatively simple. Any positional argument that you pass to `callHook` will 140 | be forwarded to the hook as-is. In our example above, the `beforeOpened` call passed `$name` to 141 | its hooks, and our hook accepted `$name` as its first argument. 142 | 143 | A collection of returned values from our hooks is available to the calling code. For example, 144 | if we wanted to allow hooks to add extra recipients to all email sent by our `Mailer` class, 145 | we might do something like: 146 | 147 | ```php 148 | use Glhd\Hooks\Hookable; 149 | 150 | class Mailer 151 | { 152 | use Hookable; 153 | 154 | protected function setRecipients() { 155 | $recipients = $this->callHook('preparingRecipients') 156 | ->filter() 157 | ->append($this->to); 158 | 159 | $this->service->setTo($recipients); 160 | } 161 | } 162 | ``` 163 | 164 | ```php 165 | // Always add QA to recipient list in staging 166 | if (App::environment('staging')) { 167 | Mailer::hook()->preparingRecipients(fn() => 'qa@myapp.com'); 168 | } 169 | ``` 170 | 171 | It's important to note that you will **always** get a collection of results, though, even 172 | if there is only one hook attached to a call, because you never know how many hooks may 173 | be registered. 174 | 175 | #### Two-way data 176 | 177 | Sometimes you need your calling code and hooks to pass the same data in two directions. A 178 | common use-case for this is when you want your hooks to have the option to abort execution, 179 | or change some default behavior. You can do this by passing named arguments to the call, 180 | which will be added to the `Context` object that is passed as the last argument to your hook. 181 | 182 | For example, what if we want hooks to have the ability to *prevent* mail from sending at all? 183 | We might do that with something like: 184 | 185 | ```php 186 | use Glhd\Hooks\Hookable; 187 | 188 | class Mailer 189 | { 190 | use Hookable; 191 | 192 | protected function send() { 193 | $result = $this->callHook('beforeSend', $this->message, shouldSend: true); 194 | 195 | if ($result->shouldSend) { 196 | $this->service->send(); 197 | } 198 | } 199 | } 200 | ``` 201 | 202 | ```php 203 | // Never send mail to mailinator addresses 204 | Mailer::hook()->beforeSend(function($message, $context) { 205 | if (str_contains($message->to, '@mailinator.com')) { 206 | $context->shouldSend = false; 207 | } 208 | }); 209 | ``` 210 | 211 | ### When to use class hooks 212 | 213 | Class hooks are mostly useful for package code that needs to be extensible without 214 | knowing **how** it will exactly be extended. The Laravel framework provides similar extension 215 | points, like [`Queue::createPayloadUsing`](https://github.com/laravel/framework/blob/443ec4438c48923c9caa9c2b409a12b84a10033f/src/Illuminate/Queue/Queue.php#L288). 216 | 217 | In general, you should avoid using class hooks in your application code unless you are 218 | dealing with particularly complex conditional logic that really warrants this approach. 219 | 220 | ## Within Views 221 | 222 | Sometimes you may want to make certain views "hook-able" as well. For example, suppose 223 | you have an ecommerce website that sends out email receipts, and you want to occasionally 224 | add promotions or other contextual content to the email message. Rather than constantly 225 | adding and removing a bunch of `@if` calls, you can use a hook: 226 | 227 | ```blade 228 | {{-- emails/receipt.blade.php --}} 229 | Thank you for shopping at… 230 | 231 | 232 | 233 | Your receipt info… 234 | 235 | 236 | ``` 237 | 238 | Now you have two spots that you can hook into… 239 | 240 | ```php 241 | // Somewhere in a `PromotionsServiceProvider` class, perhaps… 242 | 243 | if ($this->isInCyberMondayPromotionalPeriod()) { 244 | View::hook('emails.receipt', 'intro', fn() => view('emails.promotions._cyber_monday_intro')); 245 | } 246 | 247 | if (Auth::user()->isNewRegistrant()) { 248 | View::hook('emails.receipt', 'footer', fn() => view('emails.promotions._thank_you_for_first_purchase')); 249 | } 250 | ``` 251 | 252 | The `View::hook` method accepts 4 arguments. The first is the view name that you're 253 | hooking into; the second is the name of the hook itself. The third argument can either 254 | be a view (or anything that implements the `Htmlable` contract), or a closure that returns 255 | anything that Blade can render. Finally, the fourth argument is a `priority` value—the lower 256 | the priority, the earlier it will be rendered (if there are multiple things hooking into 257 | the same spot). If you do not provide a priority, it will be set the `1000` by default. 258 | 259 | ### Explicitly Setting View Name 260 | 261 | The `` Blade component can usually infer what view it's being rendered inside. 262 | Depending on how your views are rendered, though, you may need to explicitly pass the view 263 | name to the component. You can do that by passing an additional `view` prop: 264 | 265 | ```blade 266 | 267 | ``` 268 | 269 | This is a requirement that we hope to improve in a future release! 270 | 271 | ### View Hook Attributes 272 | 273 | It's possible to pass component attributes to your hooks, using regular Blade syntax: 274 | 275 | ```blade 276 | 277 | ``` 278 | 279 | Your hooks will then receive the `status` value (and any other attributes you pass): 280 | 281 | ```php 282 | View::hook('my.view', 'status', function($attributes) { 283 | assert($attributes['status'] === 'Demoing hooks'); 284 | }); 285 | ``` 286 | 287 | If you pass the hook a Laravel view, any attributes will automatically be forwarded. 288 | This means that you can use the `$status` variable inside your view. For example, 289 | given the following views: 290 | 291 | ```blade 292 | {{-- my/view.blade.php --}} 293 | 294 | 295 | {{-- my/hook.blade.php --}} 296 |
297 | Your current status is '{{ $status }}' 298 |
299 | ``` 300 | 301 | The following hook code would automatically forward the value `"Demoing hooks"` as 302 | the `$status` attribute in your `my.hook` view: 303 | 304 | ```php 305 | View::hook('my.view', 'status', view('my.hook')); 306 | ``` 307 | -------------------------------------------------------------------------------- /bin/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Move into project root 4 | BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 | cd "$BIN_DIR" 6 | cd .. 7 | 8 | # Exit on errors 9 | set -e 10 | 11 | CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '***.php') 12 | 13 | if [[ -z "$CHANGED_FILES" ]]; then 14 | echo 'No changed files' 15 | exit 0 16 | fi 17 | 18 | if [[ -x vendor/bin/php-cs-fixer ]]; then 19 | vendor/bin/php-cs-fixer fix --config .php-cs-fixer.dist.php $CHANGED_FILES 20 | git add $CHANGED_FILES 21 | else 22 | echo 'php-cs-fixer is not installed' 23 | exit 1 24 | fi 25 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glhd/hooks", 3 | "description": "", 4 | "keywords": [ 5 | "laravel" 6 | ], 7 | "authors": [ 8 | { 9 | "name": "Chris Morrell", 10 | "homepage": "http://www.cmorrell.com" 11 | } 12 | ], 13 | "type": "library", 14 | "license": "MIT", 15 | "require": { 16 | "php": ">=8.1", 17 | "illuminate/support": "^10|^11|^12|13.x-dev|dev-master|dev-main", 18 | "spatie/laravel-package-tools": "^1.15" 19 | }, 20 | "require-dev": { 21 | "orchestra/testbench": "^8.33|^9.11|^10.0|11.x-dev|dev-master|dev-main", 22 | "friendsofphp/php-cs-fixer": "^3.34", 23 | "phpunit/phpunit": "^10.5|^11.5", 24 | "projektgopher/whisky": "^0.5.1" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Glhd\\Hooks\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "classmap": [ 33 | "tests/TestCase.php" 34 | ], 35 | "psr-4": { 36 | "Glhd\\Hooks\\Tests\\": "tests/" 37 | } 38 | }, 39 | "scripts": { 40 | "post-install-cmd": [ 41 | "whisky update" 42 | ], 43 | "post-update-cmd": [ 44 | "whisky update" 45 | ], 46 | "fix-style": "vendor/bin/php-cs-fixer fix", 47 | "check-style": "vendor/bin/php-cs-fixer fix --diff --dry-run" 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Glhd\\Hooks\\Support\\HooksServiceProvider" 53 | ] 54 | } 55 | }, 56 | "minimum-stability": "dev", 57 | "prefer-stable": true 58 | } 59 | -------------------------------------------------------------------------------- /config/hooks.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./src 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | data)) { 23 | throw new OutOfBoundsException("No such result data exists: '{$name}'"); 24 | } 25 | 26 | return $this->data[$name]; 27 | } 28 | 29 | public function __set(string $name, $value): void 30 | { 31 | $this->data[$name] = $value; 32 | } 33 | 34 | public function __isset(string $name): bool 35 | { 36 | return isset($this->data[$name]); 37 | } 38 | 39 | public function __call(string $name, array $arguments) 40 | { 41 | return Collection::make($this->results)->{$name}(...$arguments); 42 | } 43 | 44 | public function addResult(mixed $result): static 45 | { 46 | $this->results[] = $result; 47 | 48 | return $this; 49 | } 50 | 51 | public function hasResults(): bool 52 | { 53 | return count($this->results) > 0; 54 | } 55 | 56 | public function stopPropagation(): void 57 | { 58 | $this->should_stop_propagation = true; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Hook.php: -------------------------------------------------------------------------------- 1 | callback, $arguments); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Hookable.php: -------------------------------------------------------------------------------- 1 | get(static::class); 22 | 23 | if ($callback) { 24 | $hooks->default($callback, $priority); 25 | } 26 | 27 | return $hooks; 28 | } 29 | 30 | protected function callHook(BackedEnum|string $name, ...$args): Context 31 | { 32 | if ($name instanceof BackedEnum) { 33 | if (! is_string($name->value)) { 34 | throw new TypeError('Name must be either a string or an enum backed by a string'); 35 | } 36 | 37 | $name = $name->value; 38 | } 39 | 40 | return app(HookRegistry::class) 41 | ->get(static::class) 42 | ->run($name, $args); 43 | } 44 | 45 | protected function callDefaultHook(...$args): Context 46 | { 47 | return $this->callHook(Hooks::DEFAULT, ...$args); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Hooks.php: -------------------------------------------------------------------------------- 1 | */ 14 | protected array $hooks = []; 15 | 16 | public function __construct( 17 | public string $target, 18 | ) { 19 | } 20 | 21 | public function __call(string $name, array $arguments): static 22 | { 23 | $hook = $arguments[0]; 24 | $priority = $arguments[1] ?? Hook::DEFAULT_PRIORITY; 25 | 26 | $this->on($name, $hook, $priority); 27 | 28 | return $this; 29 | } 30 | 31 | public function default(Closure|Hook $hook, int $priority = Hook::DEFAULT_PRIORITY): static 32 | { 33 | $this->on(static::DEFAULT, $hook, $priority); 34 | 35 | return $this; 36 | } 37 | 38 | public function on(BackedEnum|string $name, Closure|Hook $hook, int $priority = Hook::DEFAULT_PRIORITY): static 39 | { 40 | if ($name instanceof BackedEnum) { 41 | if (! is_string($name->value)) { 42 | throw new TypeError('Name must be either a string or an enum backed by a string'); 43 | } 44 | 45 | $name = $name->value; 46 | } 47 | 48 | if ($hook instanceof Closure) { 49 | $hook = new Hook($hook, $priority); 50 | } 51 | 52 | $this->hooks[$name][] = $hook; 53 | $this->sortHooksByPriority($name); 54 | 55 | return $this; 56 | } 57 | 58 | public function run(string $name, array $arguments): Context 59 | { 60 | [$arguments, $data] = $this->partition($arguments); 61 | 62 | $context = new Context($data); 63 | 64 | foreach ($this->getHooks($name) as $hook) { 65 | $context->addResult($hook([...$arguments, $context])); 66 | 67 | if ($context->should_stop_propagation) { 68 | break; 69 | } 70 | } 71 | 72 | return $context; 73 | } 74 | 75 | protected function partition(array $arguments): array 76 | { 77 | $positional = []; 78 | $named = []; 79 | 80 | foreach ($arguments as $key => $value) { 81 | if (is_int($key)) { 82 | $positional[] = $value; 83 | } else { 84 | $named[$key] = $value; 85 | } 86 | } 87 | 88 | return [$positional, $named]; 89 | } 90 | 91 | protected function sortHooksByPriority(string $name): void 92 | { 93 | usort($this->hooks[$name], static function(Hook $a, Hook $b) { 94 | return $a->priority <=> $b->priority; 95 | }); 96 | } 97 | 98 | /** @return array */ 99 | protected function getHooks(string $name): array 100 | { 101 | return $this->hooks[$name] ?? []; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Support/Facades/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glhd/hooks/d29a14439633960b5484dde9d134f7a8c515e420/src/Support/Facades/.gitkeep -------------------------------------------------------------------------------- /src/Support/Facades/Hook.php: -------------------------------------------------------------------------------- 1 | hooks[$target] ??= new Hooks($target); 16 | } 17 | 18 | public function register(Hook $hook, string $target, string $name = Hooks::DEFAULT): static 19 | { 20 | $this->get($target)->on($name, $hook); 21 | 22 | return $this; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Support/HooksServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('hooks') 24 | ->setBasePath(dirname(__FILE__, 2)) 25 | ->hasConfigFile(); 26 | 27 | $this->app->scoped(HookRegistry::class); 28 | $this->app->singleton(Observer::class); 29 | 30 | Blade::component('hook', HookComponent::class); 31 | } 32 | 33 | public function packageBooted() 34 | { 35 | $observer = $this->app->make(Observer::class)->observe(); 36 | 37 | View::macro('hook', function( 38 | string $view, 39 | string $name, 40 | Closure|Htmlable|Hook $hook, 41 | int $priority = Hook::DEFAULT_PRIORITY 42 | ) use ($observer) { 43 | $wrapper = function(Context $context) use ($observer, $hook) { 44 | // Unwrap the hook 45 | while ($hook instanceof Closure) { 46 | $hook = $hook($context); 47 | } 48 | 49 | // If it's a view, render it 50 | if ($hook instanceof Htmlable) { 51 | $hook = $observer->withoutObserving(function() use ($hook, $context) { 52 | if ($hook instanceof ViewContract) { 53 | $hook->with($context->data); 54 | } 55 | 56 | return new HtmlString($hook->toHtml()); 57 | }); 58 | } 59 | 60 | return $hook; 61 | }; 62 | 63 | app(HookRegistry::class) 64 | ->get($view) 65 | ->on($name, $wrapper, $priority); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/View/Components/Hook.php: -------------------------------------------------------------------------------- 1 | view ?? $this->observer->active_view->getName(); 23 | $attributes = $data['attributes']->getAttributes(); 24 | 25 | $context = $this->registry 26 | ->get($view) 27 | ->run($this->name, $attributes); 28 | 29 | return $context->hasResults() 30 | ? $context->map(fn($result) => e($result))->join('') 31 | : e(data_get($data, 'slot')); 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/View/Observer.php: -------------------------------------------------------------------------------- 1 | factory->composer('*', function(View $view) { 24 | if ($this->observing && ! Str::startsWith($view->getName(), '__components::')) { 25 | $this->active_view = $view; 26 | } 27 | }); 28 | 29 | return $this; 30 | } 31 | 32 | public function withoutObserving(Closure $callback) 33 | { 34 | $previously_observing = $this->observing; 35 | 36 | try { 37 | $this->observing = false; 38 | return $callback(); 39 | } finally { 40 | $this->observing = $previously_observing; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/HookableTest.php: -------------------------------------------------------------------------------- 1 | afterSecond(fn() => hook_log('after second ran')); 24 | $hooks->afterFirst(fn() => hook_log('after first ran')); 25 | $hooks->beforeFirst(fn() => hook_log('before first ran')); 26 | $hooks->beforeSecond(fn() => hook_log('before second ran')); 27 | 28 | $obj = new HookableTestObject(); 29 | $obj->first(); 30 | $obj->second(); 31 | 32 | $expected = [ 33 | 'before first ran', 34 | 'first ran', 35 | 'after first ran', 36 | 'before second ran', 37 | 'second ran', 38 | 'after second ran', 39 | ]; 40 | 41 | $this->assertEquals($expected, hook_log()->all()); 42 | } 43 | 44 | public function test_hooks_can_be_registered_with_on(): void 45 | { 46 | $hooks = HookableTestObject::hook(); 47 | 48 | // We'll intentionally register our hooks out of order so that we 49 | // know that registration order doesn't matter 50 | $hooks->on('afterSecond', fn() => hook_log('after second ran')); 51 | $hooks->on('afterFirst', fn() => hook_log('after first ran')); 52 | $hooks->on('beforeFirst', fn() => hook_log('before first ran')); 53 | $hooks->on('beforeSecond', fn() => hook_log('before second ran')); 54 | 55 | $obj = new HookableTestObject(); 56 | $obj->first(); 57 | $obj->second(); 58 | 59 | $expected = [ 60 | 'before first ran', 61 | 'first ran', 62 | 'after first ran', 63 | 'before second ran', 64 | 'second ran', 65 | 'after second ran', 66 | ]; 67 | 68 | $this->assertEquals($expected, hook_log()->all()); 69 | } 70 | 71 | public function test_hooks_can_be_registered_with_on_with_enum(): void 72 | { 73 | $hooks = HookableTestObject::hook(); 74 | 75 | // We'll intentionally register our hooks out of order so that we 76 | // know that registration order doesn't matter 77 | $hooks->on(HookTestStringEnum::AfterSecond, fn() => hook_log('after second ran')); 78 | $hooks->on(HookTestStringEnum::AfterFirst, fn() => hook_log('after first ran')); 79 | $hooks->on(HookTestStringEnum::BeforeFirst, fn() => hook_log('before first ran')); 80 | $hooks->on(HookTestStringEnum::BeforeSecond, fn() => hook_log('before second ran')); 81 | 82 | $obj = new HookableTestObject(); 83 | $obj->first(); 84 | $obj->second(); 85 | 86 | $expected = [ 87 | 'before first ran', 88 | 'first ran', 89 | 'after first ran', 90 | 'before second ran', 91 | 'second ran', 92 | 'after second ran', 93 | ]; 94 | 95 | $this->assertEquals($expected, hook_log()->all()); 96 | } 97 | 98 | public function test_int_backed_enums_throw_an_exception(): void 99 | { 100 | $this->expectException(TypeError::class); 101 | 102 | HookableTestObject::hook()->on(HookTestIntEnum::One, fn() => null); 103 | } 104 | 105 | public function test_an_object_can_have_a_default_hook(): void 106 | { 107 | HookableTestObject::hook(fn() => hook_log('default hook ran')); 108 | 109 | $obj = new HookableTestObject(); 110 | $obj->first(); 111 | $obj->second(); 112 | 113 | $expected = [ 114 | 'default hook ran', 115 | 'first ran', 116 | 'default hook ran', 117 | 'second ran', 118 | ]; 119 | 120 | $this->assertEquals($expected, hook_log()->all()); 121 | } 122 | 123 | public function test_hooks_can_stop_propagation(): void 124 | { 125 | $hooks = HookableTestObject::hook(); 126 | 127 | $hooks->beforeFirst(fn() => hook_log('before first 1')); 128 | $hooks->beforeFirst(function(Context $context) { 129 | $context->stopPropagation(); 130 | hook_log('before first 2'); 131 | }); 132 | $hooks->beforeFirst(fn() => hook_log('before first 3')); 133 | 134 | $obj = new HookableTestObject(); 135 | $obj->first(); 136 | 137 | $expected = [ 138 | 'before first 1', 139 | 'before first 2', 140 | 'first ran', 141 | ]; 142 | 143 | $this->assertEquals($expected, hook_log()->all()); 144 | } 145 | 146 | public function test_context_can_be_passed_to_and_manipulated_by_hooks(): void 147 | { 148 | $obj = new HookableTestObject(); 149 | $obj->withData('foo'); 150 | 151 | HookableTestObject::hook()->on('withData', function($value, Context $context) { 152 | $this->assertEquals('bar', $context->value); 153 | $this->assertEquals('bar', $value); 154 | $context->value = 'baz'; 155 | 156 | return false; 157 | }); 158 | 159 | $obj->withData('bar'); 160 | 161 | $expected = [ 162 | 'data: foo -> foo', 163 | 'data: bar -> baz', 164 | ]; 165 | 166 | $this->assertEquals($expected, hook_log()->all()); 167 | } 168 | 169 | public function test_view_hooks_can_be_registered(): void 170 | { 171 | // We'll intentionally register our hooks out of order so that we 172 | // know that registration order doesn't matter 173 | View::hook('demo', 'header', fn() => 'Hello Skyler!', Hook::LOW_PRIORITY); 174 | View::hook('demo', 'header', view('hello', ['name' => 'Bogdan'])); 175 | View::hook('demo', 'header', fn() => view('hello', ['name' => 'Chris'])); 176 | View::hook('demo', 'footer', new HtmlString('Hello Chris!')); 177 | View::hook('demo', 'footer', view('hello', ['name' => 'Caleb'])); 178 | View::hook('demo', 'footer', fn() => view('hello', ['name' => 'Daniel'])); 179 | 180 | View::hook('demo', 'footer', function($context) { 181 | $this->assertEquals('bar', $context->foo); 182 | }); 183 | 184 | $view = $this->view('demo'); 185 | 186 | $view->assertSeeTextInOrder([ 187 | 'Hello Bogdan!', 188 | 'Hello Chris!', 189 | 'Hello Skyler!', 190 | 'This is a demo', 191 | 'Hello Chris!', 192 | 'Hello Caleb!', 193 | 'Hello Daniel!', 194 | ]); 195 | } 196 | } 197 | 198 | enum HookTestStringEnum: string 199 | { 200 | case BeforeFirst = 'beforeFirst'; 201 | case AfterFirst = 'afterFirst'; 202 | case BeforeSecond = 'beforeSecond'; 203 | case AfterSecond = 'afterSecond'; 204 | } 205 | 206 | enum HookTestIntEnum: int 207 | { 208 | case One = 1; 209 | } 210 | 211 | class HookableTestObject 212 | { 213 | use Hookable; 214 | 215 | public function first() 216 | { 217 | $this->callDefaultHook(); 218 | $this->callHook('beforeFirst'); 219 | hook_log('first ran'); 220 | $this->callHook('afterFirst'); 221 | } 222 | 223 | public function second() 224 | { 225 | $this->callDefaultHook(); 226 | $this->callHook('beforeSecond'); 227 | hook_log('second ran'); 228 | $this->callHook('afterSecond'); 229 | } 230 | 231 | public function withData(string $initial) 232 | { 233 | $result = $this->callHook('withData', $initial, value: $initial); 234 | 235 | hook_log("data: {$initial} -> {$result->value}"); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | app->make(Factory::class)->addLocation(__DIR__.'/views'); 18 | } 19 | 20 | protected function getPackageProviders($app) 21 | { 22 | return [ 23 | HooksServiceProvider::class, 24 | ]; 25 | } 26 | 27 | protected function getPackageAliases($app) 28 | { 29 | return []; 30 | } 31 | 32 | protected function getApplicationTimezone($app) 33 | { 34 | return 'America/New_York'; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/helpers.php: -------------------------------------------------------------------------------- 1 | has('glhd.hooks.log')) { 8 | app()->instance('glhd.hooks.log', new Collection()); 9 | } 10 | 11 | $log = app('glhd.hooks.log'); 12 | 13 | if ($message) { 14 | $log->push($message); 15 | } 16 | 17 | return $log; 18 | } 19 | -------------------------------------------------------------------------------- /tests/views/demo.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |

This is a demo

5 | 6 | 7 |
8 | -------------------------------------------------------------------------------- /tests/views/hello.blade.php: -------------------------------------------------------------------------------- 1 | Hello {{ $name }}! 2 | -------------------------------------------------------------------------------- /whisky.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": [], 3 | "hooks": { 4 | "pre-commit": [ 5 | "./bin/pre-commit.sh" 6 | ] 7 | } 8 | } 9 | --------------------------------------------------------------------------------