├── .php_cs.dist.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── attribute-observer.php └── src ├── Commands ├── LaravelAttributeObserverMakeCommand.php └── stubs │ └── attribute-observer.stub └── LaravelAttributeObserverServiceProvider.php /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one', 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | 'single_trait_insert_per_statement' => true, 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-attribute-observer` will be documented in this file. 4 | 5 | ## 1.6.0 - 2025-03-14 6 | 7 | * Compatibility update for Laravel 12+ 8 | 9 | ## 1.5.0 - 2024-02-18 10 | 11 | * Make config parsing more forgiving and easier to reason about 12 | + Convert objects to classes, in the off chance 13 | * Enforce the fact that deletion events are always valid 14 | * Ensure `created` events are captured properly by @dm-pf in https://github.com/alexstewartja/laravel-attribute-observer/pull/5 15 | * Prevent conflicts between method-local vs. arrow-function variable name 16 | * Dynamically check if the model was changed or is dirty by @dm-pf in https://github.com/alexstewartja/laravel-attribute-observer/pull/4 17 | * Fix for Laravel 9 by @neopheus in https://github.com/alexstewartja/laravel-attribute-observer/pull/3 18 | 19 | **All changes squashed**: https://github.com/alexstewartja/laravel-attribute-observer/compare/1.2.1...1.5.0 20 | 21 | ## 1.2.1 - 2021-12-02 22 | 23 | - Updated code style in Service Provider - Credit: @RobinBastiaan 24 | - Added Artisan command tests 25 | - Updated GH Sponsor/FUNDING information 26 | 27 | ## 1.0.4 - 2021-10-09 28 | 29 | - Optimized error handling logic 30 | - Updated README 31 | 32 | ## 1.0.3 - 2021-09-30 33 | 34 | - Relaxed PHP and Laravel version requirements 35 | - Updated README 36 | 37 | ## 1.0.2 - 2021-09-30 38 | 39 | - Made PHP 8+ a hard requirement 40 | - Updated README 41 | 42 | ## 1.0.1 - 2021-09-30 43 | 44 | - Optimized parsing of attribute observer methods 45 | - Updated README 46 | 47 | ## 1.0.0 - 2021-09-29 48 | 49 | - Initial release 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) alexstewartja 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Attribute Observer 2 | 3 | ![Laravel Attribute Observer - Social Image](https://banners.beyondco.de/Laravel%20Attribute%20Observer.png?theme=light&packageManager=composer+require&packageName=alexstewartja%2Flaravel-attribute-observer&pattern=floatingCogs&style=style_2&description=Observe+%28and+react+to%29+attribute+changes+made+on+Eloquent+models.&md=1&showWatermark=0&fontSize=125px&images=https%3A%2F%2Flaravel.com%2Fimg%2Flogomark.min.svg) 4 | 5 | [![Latest Stable Version](http://poser.pugx.org/alexstewartja/laravel-attribute-observer/v)](https://packagist.org/packages/alexstewartja/laravel-attribute-observer) 6 | [![Total Downloads](http://poser.pugx.org/alexstewartja/laravel-attribute-observer/downloads)](https://packagist.org/packages/alexstewartja/laravel-attribute-observer) 7 | [![License](http://poser.pugx.org/alexstewartja/laravel-attribute-observer/license)](https://packagist.org/packages/alexstewartja/laravel-attribute-observer) 8 | 9 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/alexstewartja/laravel-attribute-observer/run-tests.yml?label=tests)](https://github.com/alexstewartja/laravel-attribute-observer/actions?query=workflow%3Arun-tests+branch%3Amain) 10 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/alexstewartja/laravel-attribute-observer/php-cs-fixer.yml?label=code%20style)](https://github.com/alexstewartja/laravel-attribute-observer/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amain) 11 | 12 | [![PHP Versions Supported](http://poser.pugx.org/alexstewartja/laravel-attribute-observer/require/php)](https://packagist.org/packages/alexstewartja/laravel-attribute-observer) 13 | [![Laravel Versions Supported](https://img.shields.io/packagist/dependency-v/alexstewartja/laravel-attribute-observer/illuminate/contracts?label=laravel)](https://packagist.org/packages/alexstewartja/laravel-attribute-observer) 14 | 15 | [![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me-A_Coffee-orange?logo=buy-me-a-coffee)](https://buymeacoffee.com/alexstewartja) 16 | 17 | A Laravel package which allows you to observe (and react to) attribute changes made on Eloquent models. 18 | 19 | ## Installation 20 | 21 | You can install the package via composer: 22 | 23 | ```bash 24 | composer require alexstewartja/laravel-attribute-observer 25 | ``` 26 | 27 | ## Configuration 28 | 29 | Publish the config file (`config/attribute-observer.php`) with: 30 | ```bash 31 | php artisan vendor:publish --provider="AlexStewartJA\LaravelAttributeObserver\LaravelAttributeObserverServiceProvider" 32 | ``` 33 | 34 | This is the default content of the published config file: 35 | 36 | ```php 37 | return [ 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Attribute Observers 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Here you may configure all desired Models and their respective Attribute 44 | | Observers. For example: 45 | | 46 | | 'observers' => [ 47 | | \App\Models\Order::class => [ 48 | | \App\AttributeObservers\OrderStatusObserver::class, 49 | | ], 50 | | ] 51 | | 52 | */ 53 | 54 | 'observers' => [ 55 | // Define your model & attribute observers here... 56 | ] 57 | ]; 58 | ``` 59 | 60 | Populate the `observers` array with your desired **Model => Attribute Observer** mappings. 61 | 62 | ## Usage 63 | 64 | ### Attribute Observers 65 | 66 | The `make:laravel-attribute-observer` Artisan command is the easiest way to create a new attribute observer class: 67 | 68 | ```bash 69 | php artisan make:laravel-attribute-observer OrderStatusObserver --model=Order 70 | ``` 71 | 72 | This command will place the new attribute observer in your `App/AttributeObservers` directory. If this directory does not exist, 73 | Artisan will create it for you. Your freshly generated attribute observer will look like the following: 74 | 75 | ```php 76 | _Attribute Observer methods are always supplied with the **model instance**, the **new attribute value** and the **previous attribute 128 | value**, in that order._ 129 | 130 | ### Events 131 | 132 | You may observe all the typical CRUD events dispatched by Eloquent models during their lifecycles. Supported events are: 133 | `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`. 134 | 135 | The naming convention to follow when defining attribute observer methods is: **on|AttributeName|Event** 136 | 137 | So, let's say we want to check a user's email against a global spam/vanity mail list each time they attempt to update it, 138 | we would implement that logic in an attribute observer method called `onEmailUpdating`. 139 | 140 | > _Note that the attribute name must be in **[PascalCase](https://techterms.com/definition/pascalcase)** and the 141 | > event name must be **[Capitalized](https://wikipedia.org/wiki/Capitalization)**._ 142 | 143 | ## Testing 144 | 145 | ```bash 146 | composer test 147 | ``` 148 | 149 | ## Changelog 150 | 151 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 152 | 153 | ## Contributing 154 | 155 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 156 | 157 | ## Security Vulnerabilities 158 | 159 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 160 | 161 | ## Credits 162 | 163 | - [Alex Stewart](https://github.com/alexstewartja) 164 | - [All Contributors](../../contributors) 165 | 166 | ## License 167 | 168 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 169 | 170 | ## Donations 171 | 172 | I maintain this package in my spare time. 173 | If it's beneficial to you, consider buying me a coffee to keep it improving. 174 | 175 | [![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me-A_Coffee-orange?logo=buy-me-a-coffee)](https://buymeacoffee.com/alexstewartja) 176 | 177 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexstewartja/laravel-attribute-observer", 3 | "description": "Observe (and react to) attribute changes made on Eloquent models.", 4 | "keywords": [ 5 | "alexstewartja", 6 | "laravel", 7 | "laravel-attribute-observer" 8 | ], 9 | "homepage": "https://github.com/alexstewartja/laravel-attribute-observer", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Alex Stewart", 14 | "email": "iamalexstewart@gmail.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.4 || ^8.0 || ^8.1 || ^8.2 || ^8.3 || ^8.4", 20 | "spatie/laravel-package-tools": "~1.12.1|^1.13.0", 21 | "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12" 22 | }, 23 | "require-dev": { 24 | "nunomaduro/collision": "^4.0|^5.0|^6.0|^7.0|^8.0", 25 | "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0", 26 | "pestphp/pest": "^1.0|^2.0|^3.0", 27 | "pestphp/pest-plugin-laravel": "^1.0|^2.0|^3.0", 28 | "spatie/laravel-ray": "^1.0", 29 | "vimeo/psalm": "^5.0|^6.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "AlexStewartJA\\LaravelAttributeObserver\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "AlexStewartJA\\LaravelAttributeObserver\\Tests\\": "tests" 39 | } 40 | }, 41 | "scripts": { 42 | "test": "./vendor/bin/pest --no-coverage", 43 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 44 | }, 45 | "config": { 46 | "sort-packages": true, 47 | "allow-plugins": { 48 | "pestphp/pest-plugin": true 49 | } 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "AlexStewartJA\\LaravelAttributeObserver\\LaravelAttributeObserverServiceProvider" 55 | ] 56 | } 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true 60 | } 61 | -------------------------------------------------------------------------------- /config/attribute-observer.php: -------------------------------------------------------------------------------- 1 | [ 13 | | \App\Models\Order::class => [ 14 | | \App\AttributeObservers\OrderStatusObserver::class, 15 | | ], 16 | | ] 17 | | 18 | */ 19 | 20 | 'observers' => [ 21 | // Define your model & attribute observers here... 22 | ] 23 | ]; 24 | -------------------------------------------------------------------------------- /src/Commands/LaravelAttributeObserverMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('model') 19 | ? __DIR__ . '/stubs/attribute-observer.stub' 20 | : $this->resolveStubPath('/stubs/observer.plain.stub'); 21 | } 22 | 23 | protected function getDefaultNamespace($rootNamespace): string 24 | { 25 | return $rootNamespace . '\AttributeObservers'; 26 | } 27 | 28 | protected function getOptions(): array 29 | { 30 | return [ 31 | ['model', 'm', InputOption::VALUE_OPTIONAL, 'The model that the attribute observer applies to.'], 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Commands/stubs/attribute-observer.stub: -------------------------------------------------------------------------------- 1 | observers = config('attribute-observer.observers', []); 33 | } 34 | 35 | public function configurePackage(Package $package): void 36 | { 37 | $package 38 | ->name('laravel-attribute-observer') 39 | ->hasConfigFile() 40 | ->hasCommand(LaravelAttributeObserverMakeCommand::class); 41 | } 42 | 43 | public function boot() 44 | { 45 | parent::boot(); 46 | $this->observeModels(); 47 | } 48 | 49 | /** 50 | * This is where all the fun happens. 51 | */ 52 | private function observeModels(): void 53 | { 54 | if (empty($this->observers)) { 55 | return; 56 | } 57 | 58 | foreach (array_keys($this->observers) as $modelClass) { 59 | // Carry on if no attribute observers are defined for this model 60 | if (empty($this->observers[$modelClass])) { 61 | continue; 62 | } 63 | 64 | if (is_object($modelClass)) { 65 | $modelClass = get_class($modelClass); 66 | } 67 | 68 | if (! class_exists($modelClass)) { 69 | continue; 70 | } 71 | 72 | foreach ($this->observers[$modelClass] as $observerClass) { 73 | if (is_object($observerClass)) { 74 | $observerClass = get_class($observerClass); 75 | } 76 | 77 | if (! class_exists($observerClass)) { 78 | continue; 79 | } 80 | 81 | $observerInstance = App::make($observerClass); 82 | $observerEventsAttribs = $this->parseObserverMethods($observerInstance); 83 | $observedEvents = array_keys($observerEventsAttribs); 84 | 85 | foreach ($observedEvents as $observedEvent) { 86 | $modelClass::{$observedEvent}(function (Model $model) use ($observedEvent, $observerEventsAttribs, $observerInstance) { 87 | if (! $this->wasChanged($model, $observedEvent)) { 88 | return; 89 | } 90 | 91 | foreach ($observerEventsAttribs[$observedEvent] as $attribute) { 92 | if ($this->modelHasAttribute($model, $attribute) && $this->wasChanged($model, $observedEvent, $attribute)) { 93 | $method = 'on' . Str::studly($attribute) . Str::ucfirst($observedEvent); 94 | $observerInstance->{$method}($model, $model->getAttributeValue($attribute), $model->getOriginal($attribute)); 95 | } 96 | } 97 | }); 98 | } 99 | } 100 | } 101 | } 102 | 103 | /** 104 | * Scan an attribute observer, then parse and collate all 'legal' methods defined on it. 105 | * 106 | * @param mixed $observerObjectOrClass Object or fully qualified class name as a string 107 | * @return array 108 | */ 109 | private function parseObserverMethods($observerObjectOrClass): array 110 | { 111 | $eventsAttribsMapping = []; 112 | 113 | // Methods that react to attribute changes start with 'on'. Let's grab those... 114 | $observerMethods = array_map( 115 | static function ($method) { 116 | return Str::startsWith($method, 'on') ? substr($method, 2) : false; 117 | }, 118 | get_class_methods($observerObjectOrClass) 119 | ); 120 | 121 | foreach ($observerMethods as $observerMethod) { 122 | if (! $observerMethod) { 123 | continue; 124 | } 125 | 126 | // The last capitalized word in the method's name is always the event being reacted to 127 | preg_match(self::EVENT_REGEX, $observerMethod, $matches); 128 | $event = strtolower($matches[1]); 129 | 130 | if (in_array($event, self::EVENTS)) { 131 | $attribute = Str::snake(str_replace($matches[1], '', $observerMethod)); 132 | 133 | $eventsAttribsMapping[$event][] = $attribute; 134 | } 135 | } 136 | 137 | return $eventsAttribsMapping; 138 | } 139 | 140 | /** 141 | * Comprehensively check for the presence of an attribute on a model instance. 142 | * 143 | * @param Model $model 144 | * @param string $attribute 145 | * @return bool 146 | */ 147 | private function modelHasAttribute(Model $model, string $attribute): bool 148 | { 149 | return ! method_exists($model, $attribute) && 150 | (array_key_exists($attribute, $model->getAttributes()) || 151 | array_key_exists($attribute, $model->getCasts()) || 152 | $model->hasGetMutator($attribute) || 153 | array_key_exists($attribute, $model->getRelations())); 154 | } 155 | 156 | /** 157 | * Dynamically check if the model was changed or is dirty. 158 | * 159 | * @param Model $model 160 | * @param string $event 161 | * @param string|null $attribute 162 | * @return bool 163 | */ 164 | private function wasChanged(Model $model, string $event, string $attribute = null): bool 165 | { 166 | // If the model will be/was deleted then all `delet*` events are valid 167 | if (Str::startsWith($event, 'delet')) { 168 | return true; 169 | } 170 | 171 | // If the model was just inserted then all `created` events are valid 172 | if ($event === 'created' && $model->wasRecentlyCreated) { 173 | return true; 174 | } 175 | 176 | // Pull past-tense/post-mutation events from constants array 177 | $postEvents = array_filter(self::EVENTS, fn ($e) => Str::endsWith($e, 'ed')); 178 | 179 | if (in_array($event, $postEvents)) { 180 | return $attribute 181 | ? $model->wasChanged($attribute) 182 | : $model->wasChanged(); 183 | } 184 | 185 | return $attribute 186 | ? $model->isDirty($attribute) 187 | : $model->isDirty(); 188 | } 189 | } 190 | --------------------------------------------------------------------------------