├── LICENSE
├── README.md
├── composer.json
├── composer.lock
└── src
└── AttributeEvents.php
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020-2022 Jan-Paul Kleemans
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | ```php
12 | class Order extends Model
13 | {
14 | protected $dispatchesEvents = [
15 | 'status:shipped' => OrderShipped::class,
16 | 'note:*' => OrderNoteChanged::class,
17 | ];
18 | }
19 | ```
20 |
21 | Eloquent models fire several handy events throughout their lifecycle, like `created` and `deleted`. However, there are usually many more business meaningful events that happen during a model's life. With this library you can capture those, by mapping attribute changes to your own event classes.
22 |
23 | ## Installation
24 | ```bash
25 | composer require jpkleemans/attribute-events
26 | ```
27 |
28 | ## How to use it
29 | Use the `Kleemans\AttributeEvents` trait in your model and add the attributes to the `$dispatchesEvents` property:
30 |
31 | ```php
32 | class Order extends Model
33 | {
34 | use AttributeEvents;
35 |
36 | protected $dispatchesEvents = [
37 | 'created' => OrderPlaced::class,
38 | 'status:canceled' => OrderCanceled::class,
39 | 'note:*' => OrderNoteChanged::class,
40 | ];
41 | }
42 | ```
43 |
44 | The attribute events will be dispatched after the updated model is saved. Each event receives the instance of the model through its constructor.
45 |
46 | > For more info on model events and the `$dispatchesEvents` property, visit the Laravel Docs
47 |
48 | ## Listening
49 | Now you can subscribe to the events via the `EventServiceProvider` `$listen` array, or manually with Closure based listeners:
50 |
51 | ```php
52 | Event::listen(function (OrderCanceled $event) {
53 | // Restock inventory
54 | });
55 | ```
56 |
57 | Or push realtime updates to your users, using Laravel's broadcasting feature:
58 |
59 | ```js
60 | Echo.channel('orders')
61 | .listen('OrderShipped', (event) => {
62 | // Display a notification
63 | })
64 | ```
65 |
66 | ## JSON attributes
67 | For attributes stored as JSON, you can use the `->` operator:
68 |
69 | ```php
70 | protected $dispatchesEvents = [
71 | 'payment->status:completed' => PaymentCompleted::class,
72 | ];
73 | ```
74 |
75 | ## Accessors
76 | For more complex state changes, you can use attributes defined by an accessor:
77 |
78 | ```php
79 | class Product extends Model
80 | {
81 | protected $dispatchesEvents = [
82 | 'low_stock:true' => ProductReachedLowStock::class,
83 | ];
84 |
85 | public function getLowStockAttribute(): bool
86 | {
87 | return $this->stock <= 3;
88 | }
89 | }
90 | ```
91 |
92 | > You can also use the [new way of defining accessors](https://laravel.com/docs/9.x/releases#eloquent-accessors-and-mutators) introduced in Laravel 9.
93 |
94 | ## Learn more
95 | - [“Decouple your Laravel code using Attribute Events”](https://jpkleemans.medium.com/decouple-your-laravel-code-using-attribute-events-de8f2528f46a) by Jan-Paul Kleemans
96 | - [Laravel Docs on Model Events](https://laravel.com/docs/eloquent#events)
97 |
98 | ## Sponsors
99 |
100 |
101 |
102 |
103 |
104 | Thanks to Nexxtmove for sponsoring the development of this project.
105 | Your logo or name here? [Sponsor this project](https://github.com/sponsors/jpkleemans).
106 |
107 | ## License
108 |
109 | Code released under the [MIT License](https://github.com/jpkleemans/attribute-events/blob/master/LICENSE).
110 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jpkleemans/attribute-events",
3 | "description": "🔥 Fire events on attribute changes of your Eloquent model",
4 | "homepage": "https://attribute.events/",
5 | "keywords": [
6 | "laravel",
7 | "eloquent",
8 | "events",
9 | "attributes",
10 | "domain events",
11 | "model events",
12 | "ddd"
13 | ],
14 | "license": "MIT",
15 | "authors": [
16 | {
17 | "name": "Jan-Paul Kleemans",
18 | "email": "jpkleemans@gmail.com"
19 | }
20 | ],
21 | "autoload": {
22 | "psr-4": {
23 | "Kleemans\\": "src/"
24 | }
25 | },
26 | "autoload-dev": {
27 | "psr-4": {
28 | "Kleemans\\Tests\\": "tests/"
29 | }
30 | },
31 | "require": {
32 | "php": "^7.3|^8.0"
33 | },
34 | "require-dev": {
35 | "phpunit/phpunit": "^11.5",
36 | "illuminate/events": "^11.44",
37 | "illuminate/database": "^11.44",
38 | "friendsofphp/php-cs-fixer": "^3.75"
39 | },
40 | "scripts": {
41 | "tests": "phpunit ./tests",
42 | "lint": "php-cs-fixer fix"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/AttributeEvents.php:
--------------------------------------------------------------------------------
1 | fireAttributeEvents();
17 | });
18 |
19 | static::retrieved(function ($model) {
20 | $model->syncOriginalAccessors();
21 | });
22 |
23 | static::saved(function ($model) {
24 | $model->syncOriginalAccessors();
25 | });
26 | }
27 |
28 | private function fireAttributeEvents(): void
29 | {
30 | foreach ($this->getAttributeEvents() as $change => $event) {
31 | [$attribute, $expected] = explode(':', $change);
32 |
33 | try {
34 | $value = $this->getAttribute($attribute);
35 | } catch (MissingAttributeException) {
36 | continue;
37 | }
38 |
39 | // Accessor
40 | if ($this->hasAccessor($attribute)) {
41 | if (!$this->isDirtyAccessor($attribute)) {
42 | continue; // Not changed
43 | }
44 | }
45 |
46 | // JSON attribute
47 | elseif (Str::contains($attribute, '->')) {
48 | [$attribute, $path] = explode('->', $attribute, 2);
49 | $path = str_replace('->', '.', $path);
50 |
51 | if (!$this->isDirtyNested($attribute, $path)) {
52 | continue; // Not changed
53 | }
54 |
55 | try {
56 | $value = Arr::get($this->getAttribute($attribute), $path);
57 | } catch (MissingAttributeException) {
58 | continue;
59 | }
60 | }
61 |
62 | // Regular attribute
63 | elseif (!$this->isDirty($attribute)) {
64 | continue; // Not changed
65 | }
66 |
67 | if ($this->shouldFireAttributeEvent($value, $expected)) {
68 | $this->fireModelEvent($change, false);
69 | }
70 | }
71 | }
72 |
73 | private function shouldFireAttributeEvent($value, $expected)
74 | {
75 | if ($expected === '*') {
76 | return true;
77 | }
78 |
79 | if ($value instanceof \UnitEnum) {
80 | return $value->name === $expected;
81 | }
82 |
83 | if ($expected === 'true') {
84 | return $value === true;
85 | }
86 |
87 | if ($expected === 'false') {
88 | return $value === false;
89 | }
90 |
91 | // Float
92 | if (is_numeric($expected) && Str::contains($expected, '.')) {
93 | return $value === (float) $expected;
94 | }
95 |
96 | // Int
97 | if (is_numeric($expected)) {
98 | return $value === (int) $expected;
99 | }
100 |
101 | return (string) $value === $expected;
102 | }
103 |
104 | private function syncOriginalAccessors(): void
105 | {
106 | foreach ($this->getAttributeEvents() as $change => $event) {
107 | [$attribute] = explode(':', $change);
108 |
109 | if (!$this->hasAccessor($attribute)) {
110 | continue; // Attribute does not have accessor
111 | }
112 |
113 | try {
114 | $value = $this->getAttribute($attribute);
115 | } catch (MissingAttributeException) {
116 | continue;
117 | }
118 |
119 | if ($value === null) {
120 | continue; // Attribute does not exist
121 | }
122 |
123 | $this->originalAccessors[$attribute] = $value;
124 | }
125 | }
126 |
127 | public function isDirtyAccessor(string $attribute): bool
128 | {
129 | if (!isset($this->originalAccessors[$attribute])) {
130 | return false; // Attribute does not have a original value saved
131 | }
132 |
133 | $originalValue = $this->originalAccessors[$attribute];
134 |
135 | try {
136 | $currentValue = $this->getAttribute($attribute);
137 | } catch (MissingAttributeException) {
138 | return false;
139 | }
140 |
141 | return $originalValue !== $currentValue;
142 | }
143 |
144 | public function isDirtyNested(string $attribute, string $path): bool
145 | {
146 | $originalValue = Arr::get($this->getOriginal($attribute), $path);
147 |
148 | try {
149 | $currentValue = Arr::get($this->getAttribute($attribute), $path);
150 | } catch (MissingAttributeException) {
151 | return false;
152 | }
153 |
154 | if ($currentValue === null) {
155 | return false;
156 | }
157 |
158 | return $originalValue !== $currentValue;
159 | }
160 |
161 | /**
162 | * @return array
163 | */
164 | private function getAttributeEvents(): iterable
165 | {
166 | foreach ($this->dispatchesEvents as $change => $event) {
167 | if (!Str::contains($change, ':')) {
168 | continue; // Not an attribute event
169 | }
170 |
171 | yield $change => $event;
172 | }
173 | }
174 |
175 | private function hasAccessor(string $attribute): bool
176 | {
177 | if ($this->hasGetMutator($attribute)) {
178 | return true;
179 | }
180 |
181 | // Check if `hasAttributeGetMutator` exists to maintain compatibility with versions before Laravel 9.
182 | if (method_exists($this, 'hasAttributeGetMutator') && $this->hasAttributeGetMutator($attribute)) {
183 | return true;
184 | }
185 |
186 | return false;
187 | }
188 | }
189 |
--------------------------------------------------------------------------------