├── .github
└── workflows
│ └── tests.yml
├── .gitignore
├── .styleci.yml
├── README.md
├── composer.json
├── migrations
└── 2021_00_00_000000_create_states_table.php
├── phpunit.xml
├── src
├── Contracts
│ └── Stateful.php
├── Exceptions
│ ├── FinalStateException.php
│ └── TransitionException.php
├── HasStates.php
├── Models
│ └── State.php
├── State.php
├── States.php
├── StatesServiceProvider.php
└── Transition.php
└── tests
├── ModelIntegrationTest.php
├── StateTest.php
└── TestCase.php
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: tests
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: "0 0 * * *"
8 |
9 | jobs:
10 | tests:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | fail-fast: true
15 | matrix:
16 | php: [7.4, 8.0]
17 | laravel: [8.*]
18 | stability: [prefer-stable]
19 | include:
20 | - laravel: 8.*
21 | testbench: 6.*
22 |
23 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }}
24 |
25 | steps:
26 | - name: checkout
27 | uses: actions/checkout@v2
28 |
29 | - name: Cache dependencies
30 | uses: actions/cache@v1
31 | with:
32 | path: ~/.composer/cache/files
33 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{
34 | matrix.php }}-composer-${{ hashFiles('composer.json') }}
35 |
36 | - name: Setup PHP
37 | uses: shivammathur/setup-php@v2
38 | with:
39 | php-version: ${{ matrix.php }}
40 | tools: composer:v2
41 | coverage: none
42 |
43 | - name: Install dependencies
44 | run: |
45 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
46 | composer update --${{ matrix.stability }} --prefer-dist --no-suggest
47 |
48 | - name: Execute tests
49 | run: vendor/bin/phpunit
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | /.vscode/
3 | .phpunit.result.cache
4 | composer.lock
5 | .DS_Store
6 |
--------------------------------------------------------------------------------
/.styleci.yml:
--------------------------------------------------------------------------------
1 | preset: laravel
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel States
2 |
3 | A package to make use of the **finite state pattern** in eloquent Models.
4 |
5 | The package stores all states in a database table, so all states changes and the corresponding times can be traced. Since states are mapped via a relation, no additional migrations need to be created when a new state is needed for a model.
6 |
7 | ## A Recommendation
8 |
9 | Use states wherever possible! A state can be used instead of booleans like `active` or timestamps like `declined_at` or `deleted_at`:
10 |
11 | ```php
12 | $product->state->is('active');
13 | ```
14 |
15 | This way you also know when the change to active has taken place. Also your app becomes more scalable, you can simply add an additional state if needed.
16 |
17 | ## Table Of Contents
18 |
19 | - [Setup](#setup)
20 | - [Basics](#basics)
21 | - [Usage](#usage)
22 | - [Receive The Current State](#receive-state)
23 | - [Execute Transitions](#execute-transitions)
24 | - [Eager Loading](#eager-loading)
25 | - [Query Methods](#query)
26 | - [Observer Events](#events)
27 |
28 |
29 |
30 |
31 | ## Setup
32 |
33 | 1. Install the package via composer:
34 |
35 | ```shell
36 | composer require aw-studio/laravel-states
37 | ```
38 |
39 | 2. Publish the required assets:
40 |
41 | ```shell
42 | php artisan vendor:publish --tag="states:migrations"
43 | ```
44 |
45 | 3. Run The Migrations
46 |
47 | ```php
48 | php artisan migrate
49 | ```
50 |
51 | ## Basics
52 |
53 | 1. Create A State:
54 |
55 | ```php
56 | class BookingState extends State
57 | {
58 | const PENDING = 'pending';
59 | const FAILED = 'failed';
60 | const SUCCESSFULL = 'successfull';
61 |
62 | const INITIAL_STATE = self::PENDING;
63 | const FINAL_STATES = [self::FAILED, self::SUCCESSFULL];
64 | }
65 | ```
66 |
67 | 2. Create the transitions class:
68 |
69 | ```php
70 | class BookingStateTransitions extends State
71 | {
72 | const PAYMENT_PAID = 'payment_paid';
73 | const PAYMENT_FAILED = 'payment_failed';
74 | }
75 | ```
76 |
77 | 3. Define the allowed transitions:
78 |
79 | ```php
80 | class BookingState extends State
81 | {
82 | // ...
83 |
84 | public static function config()
85 | {
86 | self::set(BookingStateTransition::PAYMENT_PAID)
87 | ->from(self::PENDING)
88 | ->to(self::SUCCESSFULL);
89 | self::set(BookingStateTransition::PAYMENT_FAILED)
90 | ->from(self::PENDING)
91 | ->to(self::FAILED);
92 | }
93 | }
94 | ```
95 |
96 | 4. Setup your Model:
97 |
98 | ```php
99 | use AwStudio\States\Contracts\Stateful;
100 | use AwStudio\States\HasStates;
101 |
102 | class Booking extends Model implements Stateful
103 | {
104 | use HasStates;
105 |
106 | protected $states = [
107 | 'state' => BookingState::class,
108 | 'payment_state' => ...,
109 | ];
110 | }
111 | ```
112 |
113 |
114 |
115 | ## Usage
116 |
117 |
118 |
119 | ### Receive The Current State
120 |
121 | ```php
122 | $booking->state->current(); // "pending"
123 | (string) $booking->state; // "pending"
124 | ```
125 |
126 | Determine if the current state is a given state:
127 |
128 | ```php
129 | if($booking->state->is(BookingState::PENDING)) {
130 | //
131 | }
132 | ```
133 |
134 | Determine if the current state is any of a the given states:
135 |
136 | ```php
137 | $states = [
138 | BookingState::PENDING,
139 | BookingState::SUCCESSFULL
140 | ];
141 | if($booking->state->isAnyOf($states)) {
142 | //
143 | }
144 | ```
145 |
146 | Determine if the state has been the given state at any time:
147 |
148 | ```php
149 | if($booking->state->was(BookingState::PENDING)) {
150 | //
151 | }
152 | ```
153 |
154 |
155 |
156 | ### Execute Transitions
157 |
158 | Execute a state transition:
159 |
160 | ```php
161 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID);
162 | ```
163 |
164 | Prevent throwing an exception when the given transition is not allowed for the current state by setting fail to `false`:
165 |
166 | ```php
167 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID, fail: false);
168 | ```
169 |
170 | Store additional information about the reason of a transition.
171 |
172 | ```php
173 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID, reason: "Mollie API call failed.");
174 | ```
175 |
176 | Determine wether the transition is allowed for the current state:
177 |
178 | ```php
179 | $booking->state->can(BookingStateTransition::PAYMENT_PAID);
180 | ```
181 |
182 | Lock the current state for update at the start of a transaction so the state can not be modified by simultansiously requests until the transaction is finished:
183 |
184 | ```php
185 | DB::transaction(function() {
186 | // Lock the current state for update:
187 | $booking->state->lockForUpdate();
188 |
189 | // ...
190 | });
191 |
192 | ```
193 |
194 |
195 |
196 | ### Eager Loading
197 |
198 | Reload the current state:
199 |
200 | ```php
201 | $booking->state->reload();
202 | ```
203 |
204 | Eager load the current state:
205 |
206 | ```php
207 | Booking::withCurrentState();
208 | Booking::withCurrentState('payment_state');
209 |
210 | $booking->loadCurrentState();
211 | $booking->loadCurrentState('payment_state');
212 | ```
213 |
214 |
215 |
216 | ### Query Methods
217 |
218 | Filter models that have or dont have a current state:
219 |
220 | ```php
221 | Booking::whereStateIs('payment_state', PaymentState::PAID);
222 | Booking::orWhereStateIs('payment_state', PaymentState::PAID);
223 | Booking::whereStateIsNot('payment_state', PaymentState::PAID);
224 | Booking::orWhereStateIsNot('payment_state', PaymentState::PAID);
225 | Booking::whereStateWas('payment_state', PaymentState::PAID);
226 | Booking::whereStateWasNot('payment_state', PaymentState::PAID);
227 | ```
228 |
229 | Receive state changes:
230 |
231 | ```php
232 | $booking->states()->get() // Get all states.
233 | $booking->states('payment_state')->get() // Get all payment states.
234 | ```
235 |
236 |
237 |
238 | ## Observer Events
239 |
240 | Listen to state changes or transitions in your model observer:
241 |
242 | ```php
243 | class BookingObserver
244 | {
245 | public function stateSuccessfull(Booking $booking)
246 | {
247 | // Gets fired when booking state changed to successfull.
248 | }
249 |
250 | public function paymentStatePaid(Booking $booking)
251 | {
252 | // Gets fired when booking payment_state changed to paid.
253 | }
254 |
255 | public function stateTransitionPaymentPaid(Booking $booking)
256 | {
257 | // Gets fired when state transition payment_paid gets fired.
258 | }
259 | }
260 | ```
261 |
262 | ## Static Methods:
263 |
264 | ```php
265 | BookingState::whereCan(BookingStateTransition::PAYMENT_PAID); // Gets states where from where the given transition can be executed.
266 | BookingState::canTransitionFrom('pending', 'cancel'); // Determines if the transition can be executed for the given state.
267 | ```
268 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aw-studio/laravel-states",
3 | "license": "MIT",
4 | "authors": [
5 | {
6 | "name": "cbl",
7 | "email": "lennart.carbe@gmail.com"
8 | }
9 | ],
10 | "scripts": {
11 | "test": "vendor/bin/phpunit"
12 | },
13 | "autoload": {
14 | "psr-4": {
15 | "AwStudio\\States\\": "src"
16 | }
17 | },
18 | "autoload-dev": {
19 | "psr-4": {
20 | "Tests\\": "tests/"
21 | }
22 | },
23 | "require": {
24 | "illuminate/database": "^8.0 | ^9.0 | ^10.0 | ^11.0",
25 | "illuminate/support": "^8.0 | ^9.0 | ^10.0 | ^11.0"
26 | },
27 | "require-dev": {
28 | "phpunit/phpunit": "^9.4",
29 | "mockery/mockery": "^1.4",
30 | "orchestra/testbench": "^6.13"
31 | },
32 | "extra": {
33 | "laravel": {
34 | "providers": [
35 | "AwStudio\\States\\StatesServiceProvider"
36 | ]
37 | },
38 | "branch-alias": {
39 | "dev-master": "1.x-dev"
40 | }
41 | },
42 | "minimum-stability": "dev",
43 | "prefer-stable": true
44 | }
45 |
--------------------------------------------------------------------------------
/migrations/2021_00_00_000000_create_states_table.php:
--------------------------------------------------------------------------------
1 | id();
18 | $table->string('type');
19 | $table->string('state');
20 | $table->string('from');
21 | $table->text('reason')->nullable();
22 | $table->string('transition')->nullable();
23 | $table->morphs('stateful');
24 | $table->timestamps();
25 | });
26 | }
27 |
28 | /**
29 | * Reverse the migrations.
30 | *
31 | * @return void
32 | */
33 | public function down()
34 | {
35 | Schema::dropIfExists('states');
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 | ./tests
17 |
18 |
19 |
20 |
21 | ./src
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/Contracts/Stateful.php:
--------------------------------------------------------------------------------
1 | 'changed',
26 | ];
27 |
28 | /**
29 | * Initialize HasStates trait.
30 | *
31 | * @return void
32 | */
33 | public function initializeHasStates()
34 | {
35 | if (! isset(static::$initialized[static::class])) {
36 | static::$initialized[static::class] = true;
37 |
38 | foreach ($this->states as $type => $state) {
39 | static::resolveRelationUsing(
40 | $this->getCurrentStateRelationName($type),
41 | function (Model $stateful) use ($type) {
42 | return $this->belongsTo(State::class, "current_{$type}_id");
43 | }
44 | );
45 | }
46 | }
47 | }
48 |
49 | /**
50 | * Get current state relation name.
51 | *
52 | * @param string $type
53 | * @return string
54 | */
55 | public function getCurrentStateRelationName($type = 'state')
56 | {
57 | return "current_{$type}";
58 | }
59 |
60 | /**
61 | * Get states.
62 | *
63 | * @return array
64 | */
65 | public function getStateTypes()
66 | {
67 | return $this->states;
68 | }
69 |
70 | /**
71 | * Get state type.
72 | *
73 | * @param string $type
74 | * @return string|null
75 | */
76 | public function getStateType($type)
77 | {
78 | return $this->getStateTypes()[$type] ?? null;
79 | }
80 |
81 | /**
82 | * Determine wether a state exists.
83 | *
84 | * @param string $type
85 | * @return bool
86 | */
87 | public function hasState($type)
88 | {
89 | return array_key_exists($type, $this->states);
90 | }
91 |
92 | /**
93 | * States relationship.
94 | *
95 | * @return States
96 | */
97 | public function states($type = null): States
98 | {
99 | $morphMany = $this->morphMany(State::class, 'stateful');
100 |
101 | $states = new States(
102 | $morphMany->getQuery(),
103 | $morphMany->getParent(),
104 | $morphMany->getQualifiedMorphType(),
105 | $morphMany->getQualifiedForeignKeyName(),
106 | $morphMany->getLocalKeyName()
107 | );
108 |
109 | if ($type) {
110 | $states->where('type', $type);
111 | }
112 |
113 | return $states;
114 | }
115 |
116 | /**
117 | * Update the current state.
118 | *
119 | * @param string $value
120 | * @param string $type
121 | * @return void
122 | */
123 | public function setState($value, $type = 'state')
124 | {
125 | $this->states()->make([
126 | 'from' => $this->getState($type),
127 | 'state' => $value,
128 | 'type' => $type,
129 | ])->save();
130 | }
131 |
132 | /**
133 | * Get the current state.
134 | *
135 | * @param string $type
136 | * @return State
137 | */
138 | public function getState($type = 'state')
139 | {
140 | $relation = $this->getCurrentStateRelationName($type);
141 |
142 | if (! $this->relationLoaded($relation)) {
143 | $this->loadCurrentState($type);
144 | }
145 |
146 | $latest = $this->getRelation($relation);
147 |
148 | if (! $latest) {
149 | return $this->getStateTypes()[$type]::INITIAL_STATE;
150 | }
151 |
152 | return $latest->state;
153 | }
154 |
155 | /**
156 | * Determine if a get mutator exists for an attribute.
157 | *
158 | * @param string $key
159 | * @return bool
160 | */
161 | public function hasGetMutator($key)
162 | {
163 | if ($this->hasState($key)) {
164 | return true;
165 | }
166 |
167 | return parent::hasGetMutator($key);
168 | }
169 |
170 | /**
171 | * Get the value of an attribute using its mutator.
172 | *
173 | * @param string $key
174 | * @param mixed $value
175 | * @return mixed
176 | */
177 | public function mutateAttribute($key, $value)
178 | {
179 | if ($this->hasState($key)) {
180 | return $this->mutatStateAttribute($key);
181 | }
182 |
183 | return parent::mutateAttribute($key, $value);
184 | }
185 |
186 | /**
187 | * Mutate state attribute.
188 | *
189 | * @param string $key
190 | * @return State
191 | */
192 | public function mutatStateAttribute($key)
193 | {
194 | $type = $this->getStateType($key);
195 |
196 | $state = new $type($this, $key);
197 |
198 | if (parent::hasGetMutator($key)) {
199 | $state = parent::mutateAttribute($key, $state);
200 | }
201 |
202 | return $state;
203 | }
204 |
205 | /**
206 | * Register a single observer with the model.
207 | *
208 | * @param object|string $class
209 | * @return void
210 | *
211 | * @throws \RuntimeException
212 | */
213 | protected function registerObserver($class)
214 | {
215 | parent::registerObserver($class);
216 | $className = $this->resolveObserverClassName($class);
217 | $reflector = new ReflectionClass($className);
218 |
219 | foreach ($reflector->getMethods() as $method) {
220 | foreach ($this->getStateTypes() as $type => $stateClass) {
221 | foreach ($this->observableStateEvents as $event => $methodPrefix) {
222 | $methodName = $methodPrefix.ucfirst(Str::camel($type));
223 | if ($method->getName() != $methodName) {
224 | continue;
225 | }
226 |
227 | static::registerModelEvent(
228 | "{$event}.{$type}",
229 | $className.'@'.$method->getName()
230 | );
231 | }
232 | foreach ($stateClass::all() as $state) {
233 | if (! $this->watchesObserverMethodState($method->getName(), $type, $state)) {
234 | continue;
235 | }
236 |
237 | static::registerModelEvent(
238 | $this->getStateEventName($type, $state),
239 | $className.'@'.$method->getName()
240 | );
241 | }
242 | foreach ($stateClass::uniqueTransitions() as $transition) {
243 | if (! $this->watchesObserverMethodStateTransition($method->getName(), $type, $transition)) {
244 | continue;
245 | }
246 |
247 | static::registerModelEvent(
248 | $this->getTransitionEventName($type, $transition),
249 | $className.'@'.$method->getName()
250 | );
251 | }
252 | }
253 | }
254 | }
255 |
256 | /**
257 | * Resolve the observer's class name from an object or string.
258 | *
259 | * @param object|string $class
260 | * @return string
261 | *
262 | * @throws \InvalidArgumentException
263 | */
264 | protected function resolveObserverClassName($class)
265 | {
266 | if (is_object($class)) {
267 | return get_class($class);
268 | }
269 |
270 | if (class_exists($class)) {
271 | return $class;
272 | }
273 |
274 | throw new InvalidArgumentException('Unable to find observer: '.$class);
275 | }
276 |
277 | /**
278 | * Determines wether an observer method watches the given state transition.
279 | *
280 | * @param string $method
281 | * @param string $type
282 | * @param string $transition
283 | * @return bool
284 | */
285 | protected function watchesObserverMethodStateTransition($method, $type, $transition)
286 | {
287 | $start = Str::camel($type).'Transition';
288 |
289 | if (! Str::startsWith($method, $start)) {
290 | return false;
291 | }
292 |
293 | $following = str_replace($start, '', $method);
294 |
295 | return $following == ucfirst(Str::camel($transition));
296 | }
297 |
298 | /**
299 | * Determines wether an observer method watches the given state.
300 | *
301 | * @param string $method
302 | * @param string $type
303 | * @param string $state
304 | * @return bool
305 | */
306 | protected function watchesObserverMethodState($method, $type, $state)
307 | {
308 | if (! Str::startsWith($method, Str::camel($type))) {
309 | return false;
310 | }
311 |
312 | $following = str_replace(Str::camel($type), '', $method);
313 |
314 | if ($following == ucfirst(Str::camel($state))) {
315 | return true;
316 | }
317 |
318 | if (! str_contains($method, 'Or')) {
319 | return false;
320 | }
321 |
322 | foreach (explode('Or', $following) as $key) {
323 | if ($key == $state) {
324 | return true;
325 | }
326 | }
327 |
328 | return false;
329 | }
330 |
331 | /**
332 | * Get state event name.
333 | *
334 | * @param string $type
335 | * @param string $state
336 | * @return string
337 | */
338 | public function getStateEventName($type, $state)
339 | {
340 | return "{$type}.{$state}";
341 | }
342 |
343 | /**
344 | * Get state event name.
345 | *
346 | * @param string $type
347 | * @param string $state
348 | * @return string
349 | */
350 | public function getStateEventMethod($type, $state)
351 | {
352 | return Str::camel("{$type}_{$state}");
353 | }
354 |
355 | /**
356 | * Get transition event name.
357 | *
358 | * @param string $type
359 | * @param string $transition
360 | * @return string
361 | */
362 | public function getTransitionEventName($type, $transition)
363 | {
364 | return "{$type}.transition.{$transition}";
365 | }
366 |
367 | /**
368 | * Get transition event name.
369 | *
370 | * @param string $type
371 | * @param string $transition
372 | * @return string
373 | */
374 | public function getTransitionEventMethod($type, $transition)
375 | {
376 | return Str::camel("{$type}_transition_{$transition}");
377 | }
378 |
379 | /**
380 | * Fire state events for the given transition.
381 | *
382 | * @param string $type
383 | * @param Transition $transition
384 | * @return void
385 | */
386 | public function fireStateEventsFor($type, Transition $transition)
387 | {
388 | $this->fireModelEvent($this->getTransitionEventName($type, $transition->name));
389 | $this->fireModelEvent($this->getStateEventName($type, $transition->to));
390 | $this->fireModelEvent("changed.{$type}");
391 | }
392 |
393 | /**
394 | * `whereDoesntHaveStates` query scope.
395 | *
396 | * @param string $type
397 | * @return void
398 | */
399 | public function scopeWhereDoesntHaveStates($query, $type = 'state')
400 | {
401 | $query->whereDoesntHave('states', function ($statesQuery) use ($type) {
402 | $statesQuery->where('type', $type);
403 | });
404 | }
405 |
406 | /**
407 | * `whereDoesntHaveStates` query scope.
408 | *
409 | * @param string $type
410 | * @return void
411 | */
412 | public function scopeOrWhereDoesntHaveStates($query, $type = 'state')
413 | {
414 | $query->orWhereDoesntHave('states', function ($statesQuery) use ($type) {
415 | $statesQuery->where('type', $type);
416 | });
417 | }
418 |
419 | /**
420 | * `whereStateWas` query scope.
421 | *
422 | * @param Builder $query
423 | * @param string $type
424 | * @param string $value
425 | * @return void
426 | */
427 | public function scopeWhereStateWas($query, $type, $value)
428 | {
429 | if ($this->getStateType($type)::INITIAL_STATE == $value) {
430 | return;
431 | }
432 |
433 | $query->whereHas('states', function ($statesQuery) use ($type, $value) {
434 | $statesQuery
435 | ->where('type', $type)
436 | ->where('state', $value);
437 | });
438 | }
439 |
440 | /**
441 | * `whereNotHaveWasNot` query scope.
442 | *
443 | * @param Builder $query
444 | * @param string $type
445 | * @param string $value
446 | * @return void
447 | */
448 | public function scopeWhereNotHaveWasNot($query, $type, $value)
449 | {
450 | $query->whereDoesntHave('states', function ($statesQuery) use ($type, $value) {
451 | $statesQuery
452 | ->where('type', $type)
453 | ->where('state', $value);
454 | });
455 | }
456 |
457 | /**
458 | * `whereStateIs` query scope.
459 | *
460 | * @param Builder $query
461 | * @param string $type
462 | * @param string $value
463 | * @return void
464 | */
465 | public function scopeWhereStateIs($query, $type, $value)
466 | {
467 | if ($this->getStateType($type)::INITIAL_STATE == $value) {
468 | return $query->whereDoesntHaveStates($type);
469 | }
470 |
471 | $query->whereExists(function ($existsQuery) use ($type, $value) {
472 | $existsQuery
473 | ->from(DB::raw((new State())->getTable().' as _s'))
474 | ->where('type', $type)
475 | ->where('stateful_type', $this->getMorphClass())
476 | ->whereColumn('stateful_id', $this->getTable().'.id')
477 | ->where('state', $value)
478 | ->whereNotExists(function ($notExistsQuery) use ($type) {
479 | $notExistsQuery->from('states')
480 | ->where('type', $type)
481 | ->where('stateful_type', $this->getMorphClass())
482 | ->whereColumn('stateful_id', $this->getTable().'.id')
483 | ->whereColumn('id', '>', '_s.id');
484 | });
485 | });
486 | }
487 |
488 | /**
489 | * `orWhereStateIs` query scope.
490 | *
491 | * @param Builder $query
492 | * @param string $type
493 | * @param string $value
494 | * @return void
495 | */
496 | public function scopeOrWhereStateIs($query, $type, $value)
497 | {
498 | if ($this->getStateType($type)::INITIAL_STATE == $value) {
499 | return $query->orWhereDoesntHaveStates($type);
500 | }
501 |
502 | $query->orWhereExists(function ($existsQuery) use ($type, $value) {
503 | $existsQuery
504 | ->from(DB::raw((new State())->getTable().' as _s'))
505 | ->where('type', $type)
506 | ->where('stateful_type', $this->getMorphClass())
507 | ->whereColumn('stateful_id', $this->getTable().'.id')
508 | ->where('state', $value)
509 | ->whereNotExists(function ($notExistsQuery) use ($type) {
510 | $notExistsQuery->from('states')
511 | ->where('type', $type)
512 | ->where('stateful_type', $this->getMorphClass())
513 | ->whereColumn('stateful_id', $this->getTable().'.id')
514 | ->whereColumn('id', '>', '_s.id');
515 | });
516 | });
517 | }
518 |
519 | /**
520 | * `whereStateIn` query scope.
521 | *
522 | * @param Builder $query
523 | * @param string $type
524 | * @param array $value
525 | * @return void
526 | */
527 | public function scopeWhereStateIsIn($query, $type, array $value)
528 | {
529 | if ($this->getStateType($type)::INITIAL_STATE == $value) {
530 | return $query->whereDoesntHaveStates($type);
531 | }
532 |
533 | $query->whereExists(function ($existsQuery) use ($type, $value) {
534 | $existsQuery
535 | ->from(DB::raw((new State())->getTable().' as _s'))
536 | ->where('type', $type)
537 | ->where('stateful_type', $this->getMorphClass())
538 | ->whereColumn('stateful_id', $this->getTable().'.id')
539 | ->whereIn('state', $value)
540 | ->whereNotExists(function ($notExistsQuery) use ($type) {
541 | $notExistsQuery->from('states')
542 | ->where('type', $type)
543 | ->where('stateful_type', $this->getMorphClass())
544 | ->whereColumn('stateful_id', $this->getTable().'.id')
545 | ->whereColumn('id', '>', '_s.id');
546 | });
547 | });
548 | }
549 |
550 | /**
551 | * `whereStateIsNot` query scope.
552 | *
553 | * @param Builder $query
554 | * @param string $type
555 | * @param string $value
556 | * @return void
557 | */
558 | public function scopeWhereStateIsNot($query, $type, $value)
559 | {
560 | $query->where(function ($query) use ($type, $value) {
561 | $query->whereExists(function ($existsQuery) use ($type, $value) {
562 | $existsQuery
563 | ->from(DB::raw((new State())->getTable().' as _s'))
564 | ->where('type', $type)
565 | ->where('stateful_type', $this->getMorphClass())
566 | ->whereColumn('stateful_id', $this->getTable().'.id')
567 | ->where('state', '!=', $value)
568 | ->whereNotExists(function ($notExistsQuery) use ($type) {
569 | $notExistsQuery->from('states')
570 | ->where('type', $type)
571 | ->where('stateful_type', $this->getMorphClass())
572 | ->whereColumn('stateful_id', $this->getTable().'.id')
573 | ->whereColumn('id', '>', '_s.id');
574 | });
575 | });
576 |
577 | if (! in_array($this->getStateType($type)::INITIAL_STATE, Arr::wrap($value))) {
578 | return $query->orWhereDoesntHaveStates($type);
579 | }
580 | });
581 | }
582 |
583 | /**
584 | * `whereStateIsNot` query scope.
585 | *
586 | * @param Builder $query
587 | * @param string $type
588 | * @param array $value
589 | * @return void
590 | */
591 | public function scopeWhereStateIsNotIn($query, $type, array $value)
592 | {
593 | $query->where(function ($query) use ($type, $value) {
594 | $query->whereExists(function ($existsQuery) use ($type, $value) {
595 | $existsQuery
596 | ->from(DB::raw((new State())->getTable().' as _s'))
597 | ->where('type', $type)
598 | ->where('stateful_type', $this->getMorphClass())
599 | ->whereColumn('stateful_id', $this->getTable().'.id')
600 | ->whereNotIn('state', $value)
601 | ->whereNotExists(function ($notExistsQuery) use ($type) {
602 | $notExistsQuery->from('states')
603 | ->where('type', $type)
604 | ->where('stateful_type', $this->getMorphClass())
605 | ->whereColumn('stateful_id', $this->getTable().'.id')
606 | ->whereColumn('id', '>', '_s.id');
607 | });
608 | });
609 |
610 | if (! in_array($this->getStateType($type)::INITIAL_STATE, Arr::wrap($value))) {
611 | return $query->orWhereDoesntHaveStates($type);
612 | }
613 | });
614 | }
615 |
616 | /**
617 | * `orWhereStateIsNot` query scope.
618 | *
619 | * @param Builder $query
620 | * @param string $type
621 | * @param string|array $value
622 | * @return void
623 | */
624 | public function scopeOrWhereStateIsNot($query, $type, $value)
625 | {
626 | $query->orWhere(function ($query) use ($type, $value) {
627 | $query->whereExists(function ($existsQuery) use ($type, $value) {
628 | $existsQuery
629 | ->from(DB::raw((new State())->getTable().' as _s'))
630 | ->where('type', $type)
631 | ->where('stateful_type', $this->getMorphClass())
632 | ->whereColumn('stateful_id', $this->getTable().'.id')
633 | ->where('state', '!=', $value)
634 | ->whereNotExists(function ($notExistsQuery) use ($type) {
635 | $notExistsQuery->from('states')
636 | ->where('type', $type)
637 | ->where('stateful_type', $this->getMorphClass())
638 | ->whereColumn('stateful_id', $this->getTable().'.id')
639 | ->whereColumn('id', '>', '_s.id');
640 | });
641 | });
642 |
643 | if (! in_array($this->getStateType($type)::INITIAL_STATE, Arr::wrap($value))) {
644 | return $query->orWhereDoesntHaveStates($type);
645 | }
646 | });
647 | }
648 |
649 | /**
650 | * `addCurrentStateSelect` query scope.
651 | *
652 | * @param Builder $query
653 | * @param string $type
654 | * @param string $select
655 | * @return void
656 | */
657 | public function scopeAddCurrentStateSelect($query, $type = 'state', $select = 'state', Closure $closure = null)
658 | {
659 | $query->addSelect(["current_{$type}_{$select}" => State::select($select)
660 | ->where('type', $type)
661 | ->where('stateful_type', $this->getMorphClass())
662 | ->whereColumn('stateful_id', $this->getTable().'.id')
663 | ->orderByDesc('id')
664 | ->take(1),
665 | ]);
666 | }
667 |
668 | /**
669 | * `withCurrentState` query scope.
670 | *
671 | * @param Builder $query
672 | * @param string $type
673 | * @return void
674 | */
675 | public function scopeWithCurrentState($query, $type = 'state')
676 | {
677 | $query->addCurrentStateSelect($type, 'id')
678 | ->with($this->getCurrentStateRelationName($type));
679 | }
680 |
681 | /**
682 | * Load state relation of the given type.
683 | *
684 | * @param string $type
685 | * @return $this
686 | */
687 | public function loadCurrentState($type = 'state')
688 | {
689 | $currentState = $this->states()
690 | ->where('type', $type)
691 | ->orderByDesc('id')
692 | ->first();
693 |
694 | $this->setRelation(
695 | $this->getCurrentStateRelationName($type),
696 | $currentState
697 | );
698 |
699 | return $this;
700 | }
701 |
702 | /**
703 | * Reload current state relation of the given type.
704 | *
705 | * @param string $type
706 | * @return $this
707 | */
708 | public function reloadState($type = 'state')
709 | {
710 | $this->loadCurrentState($type);
711 |
712 | return $this;
713 | }
714 | }
715 |
--------------------------------------------------------------------------------
/src/Models/State.php:
--------------------------------------------------------------------------------
1 | ticket_state->current());.
27 | *
28 | * @var Stateful|Model
29 | */
30 | protected $stateful;
31 |
32 | /**
33 | * State type.
34 | *
35 | * @var string
36 | */
37 | protected $type;
38 |
39 | /**
40 | * Configure the state.
41 | *
42 | * @return void
43 | */
44 | abstract public static function config();
45 |
46 | /**
47 | * Returns an array of all types.
48 | *
49 | * @return array
50 | */
51 | public static function all()
52 | {
53 | $reflector = new ReflectionClass(static::class);
54 |
55 | return collect($reflector->getConstants())
56 | ->filter(fn ($value, $key) => ! in_array($key, [
57 | 'INITIAL_STATE', 'FINAL_STATES',
58 | ]))
59 | ->values()
60 | ->toArray();
61 | }
62 |
63 | /**
64 | * Get states from where the given transition can be executed.
65 | *
66 | * @param string $transition
67 | * @return array
68 | */
69 | public static function whereCan($transition)
70 | {
71 | return collect(static::all())
72 | ->filter(function ($state) use ($transition) {
73 | return static::canTransitionFrom($state, $transition);
74 | })
75 | ->values()
76 | ->toArray();
77 | }
78 |
79 | /**
80 | * Determines wether the given transition can be executed for the given
81 | * state.
82 | *
83 | * @param string $state
84 | * @param string $transition
85 | * @return bool
86 | */
87 | public static function canTransitionFrom($state, $transition)
88 | {
89 | return (bool) collect(static::getTransitions())
90 | ->first(function (Transition $t) use ($state, $transition) {
91 | return $t->from == $state
92 | && $t->name == $transition;
93 | });
94 | }
95 |
96 | /**
97 | * Returns an array fo all transitions.
98 | *
99 | * @return array
100 | */
101 | public static function uniqueTransitions()
102 | {
103 | $transitions = [];
104 |
105 | foreach (static::getTransitions() as $transition) {
106 | if (! in_array($transition->name, $transitions)) {
107 | $transitions[] = $transition->name;
108 | }
109 | }
110 |
111 | return $transitions;
112 | }
113 |
114 | /**
115 | * Allow transition.
116 | *
117 | * @param string $transition
118 | * @return Transition
119 | */
120 | public static function set($transition)
121 | {
122 | if (! array_key_exists(static::class, static::$transitions)) {
123 | static::$transitions[static::class] = [];
124 | }
125 |
126 | $transition = new Transition($transition);
127 |
128 | static::$transitions[static::class][] = $transition;
129 |
130 | return $transition;
131 | }
132 |
133 | /**
134 | * Get transitions.
135 | *
136 | * @return array
137 | */
138 | public static function getTransitions()
139 | {
140 | if (! array_key_exists(static::class, static::$transitions)) {
141 | static::config();
142 | }
143 |
144 | return static::$transitions[static::class];
145 | }
146 |
147 | /**
148 | * Create new State instance.
149 | *
150 | * @param Stateful $stateful
151 | * @param string $type
152 | * @return void
153 | */
154 | public function __construct(Stateful $stateful, $type)
155 | {
156 | $this->stateful = $stateful;
157 | $this->type = $type;
158 | }
159 |
160 | /**
161 | * Get the state type.
162 | *
163 | * @return string
164 | */
165 | public function getType()
166 | {
167 | if (property_exists($this, 'type')) {
168 | return $this->type;
169 | }
170 |
171 | return $this->getTypeFromNamespace();
172 | }
173 |
174 | /**
175 | * Get type from namespace.
176 | *
177 | * @return string
178 | */
179 | protected function getTypeFromNamespace()
180 | {
181 | return Str::snake(class_basename(static::class));
182 | }
183 |
184 | /**
185 | * Determines if a transition can be executed.
186 | *
187 | * @param string $transition
188 | * @return bool
189 | */
190 | public function can($transition)
191 | {
192 | return (bool) $this->getCurrentTransition($transition);
193 | }
194 |
195 | /**
196 | * Get current transition.
197 | *
198 | * @param string $transition
199 | * @return Transition|void
200 | */
201 | public function getCurrentTransition($transition)
202 | {
203 | foreach ($this->getTransitions() as $t) {
204 | if ($t->name == $transition && $this->current() == $t->from) {
205 | return $t;
206 | }
207 | }
208 | }
209 |
210 | /**
211 | * Determines wether a transition exists.
212 | *
213 | * @param string $transition
214 | * @return bool
215 | */
216 | public function transitionExists($transition)
217 | {
218 | foreach ($this->getTransitions() as $t) {
219 | if ($t->name == $transition) {
220 | return true;
221 | }
222 | }
223 |
224 | return false;
225 | }
226 |
227 | /**
228 | * Lock the current state for update.
229 | *
230 | * @return $this
231 | */
232 | public function lockForUpdate()
233 | {
234 | $dbConnection = $this->stateful->states()->getQuery()->getQuery()->getConnection();
235 |
236 | if ($dbConnection instanceof \Illuminate\Database\PostgresConnection) {
237 | return $this;
238 | }
239 |
240 | $this->stateful
241 | ->states()
242 | ->where('type', $this->type)
243 | ->lockForUpdate()
244 | ->count();
245 |
246 | return $this;
247 | }
248 |
249 | /**
250 | * Execute transition.
251 | *
252 | * @param string $transition
253 | * @param string $fail
254 | * @param string $reason
255 | * @return void
256 | *
257 | * @throws TransitionException
258 | */
259 | public function transition($name, $fail = true, $reason = null)
260 | {
261 | [$state, $transition] = DB::transaction(function () use ($name, $fail, $reason) {
262 | $this->reload()->lockForUpdate();
263 | if (! $this->can($name)) {
264 | if ($this->transitionExists($name)) {
265 | Log::warning('Unallowed transition.', [
266 | 'transition' => $name,
267 | 'type' => $this->type,
268 | 'current' => $this->current(),
269 | ]);
270 | }
271 |
272 | if (! $fail) {
273 | return;
274 | }
275 |
276 | throw new TransitionException(
277 | "Transition [{$name}] to change [{$this->type}] not allowed for [".$this->current().']'
278 | );
279 | }
280 |
281 | $transition = $this->getCurrentTransition($name);
282 |
283 | $state = $this->stateful->states()->makeFromTransition(
284 | $this->getType(),
285 | $transition,
286 | $reason
287 | );
288 | $state->save();
289 |
290 | return [$state, $transition];
291 | }, 5);
292 |
293 | if (! $transition) {
294 | return;
295 | }
296 |
297 | $this->stateful->setRelation(
298 | $this->stateful->getCurrentStateRelationName($this->getType()),
299 | $state
300 | );
301 |
302 | $this->stateful->fireStateEventsFor($this->getType(), $transition);
303 |
304 | return $state;
305 | }
306 |
307 | /**
308 | * Get current state.
309 | *
310 | * @return string
311 | */
312 | public function current()
313 | {
314 | return $this->stateful->getState(
315 | $this->getType()
316 | );
317 | }
318 |
319 | /**
320 | * Determine wether state was the given state.
321 | *
322 | * @return bool
323 | */
324 | public function was($state)
325 | {
326 | if ($state == static::INITIAL_STATE) {
327 | return true;
328 | }
329 |
330 | return $this->stateful
331 | ->states($this->getType())
332 | ->where('state', $state)
333 | ->exists();
334 | }
335 |
336 | /**
337 | * Determine if the current state is the given state.
338 | *
339 | * @param string|array $state
340 | * @return bool
341 | */
342 | public function is($state)
343 | {
344 | return in_array($this->current(), Arr::wrap($state));
345 | }
346 |
347 | /**
348 | * Determine if the current state is any of the given states.
349 | *
350 | * @param array $states
351 | * @return bool
352 | */
353 | public function isAnyOf($states)
354 | {
355 | return in_array($this->current(), $states);
356 | }
357 |
358 | /**
359 | * Reload the state.
360 | *
361 | * @return $this
362 | */
363 | public function reload()
364 | {
365 | $this->stateful->reloadState($this->getType());
366 |
367 | return $this;
368 | }
369 |
370 | /**
371 | * Format to string.
372 | *
373 | * @return string
374 | */
375 | public function __toString()
376 | {
377 | return $this->current();
378 | }
379 |
380 | /**
381 | * Convert the object to its JSON representation.
382 | *
383 | * @param int $options
384 | * @return string
385 | */
386 | public function toJson($options = 0)
387 | {
388 | return json_encode($this->current(), $options);
389 | }
390 | }
391 |
--------------------------------------------------------------------------------
/src/States.php:
--------------------------------------------------------------------------------
1 | parent->getStateType($type)) {
20 | return;
21 | }
22 |
23 | if (! $this->parent->{$type} instanceof State) {
24 | return;
25 | }
26 |
27 | return $this->parent->{$type}->executeTransition($transition);
28 | }
29 |
30 | /**
31 | * Make state from transition.
32 | *
33 | * @param string $type
34 | * @param Transition $transition
35 | * @param string|null $reason
36 | * @return StateModel
37 | */
38 | public function makeFromTransition($type, Transition $transition, $reason = null)
39 | {
40 | return $this->make([
41 | 'transition' => $transition->name,
42 | 'from' => $transition->from,
43 | 'state' => $transition->to,
44 | 'type' => $type,
45 | 'reason' => $reason,
46 | ]);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/StatesServiceProvider.php:
--------------------------------------------------------------------------------
1 | publishes([
17 | __DIR__.'/../migrations' => database_path('migrations'),
18 | ], 'states:migrations');
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Transition.php:
--------------------------------------------------------------------------------
1 | name = $transition;
37 | }
38 |
39 | /**
40 | * Set from state.
41 | *
42 | * @param string $state
43 | * @return $this
44 | */
45 | public function from($state)
46 | {
47 | $this->from = $state;
48 |
49 | return $this;
50 | }
51 |
52 | /**
53 | * Set to state.
54 | *
55 | * @param string $state
56 | * @return $this
57 | */
58 | public function to($state)
59 | {
60 | $this->to = $state;
61 |
62 | return $this;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/tests/ModelIntegrationTest.php:
--------------------------------------------------------------------------------
1 | loadMigrationsFrom(__DIR__.'/../migrations');
23 |
24 | Schema::create('bookings', function (Blueprint $table) {
25 | $table->id();
26 | });
27 | }
28 |
29 | public function tearDown(): void
30 | {
31 | Schema::drop('bookings');
32 | parent::tearDown();
33 | }
34 |
35 | /** @test */
36 | public function it_has_initial_state()
37 | {
38 | $booking = new Booking();
39 | $this->assertNotNull($booking->state);
40 | $this->assertInstanceOf(BookingState::class, $booking->state);
41 | $this->assertSame(BookingState::INITIAL_STATE, $booking->state->current());
42 | }
43 |
44 | /** @test */
45 | public function it_executes_transition()
46 | {
47 | $booking = Booking::create();
48 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID);
49 | $this->assertSame(BookingState::SUCCESSFULL, $booking->state->current());
50 |
51 | $booking = Booking::create();
52 | $booking->state->transition(BookingStateTransition::PAYMENT_FAILED);
53 | $this->assertSame(BookingState::FAILED, $booking->state->current());
54 | }
55 |
56 | /** @test */
57 | public function it_fails_for_invalid_transition()
58 | {
59 | $this->expectException(TransitionException::class);
60 | $booking = Booking::create();
61 | $booking->state->transition('foo');
62 | }
63 |
64 | /** @test */
65 | public function test_state_getter()
66 | {
67 | $booking = Booking::create();
68 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID);
69 | $this->assertInstanceOf(State::class, $booking->state);
70 | $this->assertSame(BookingState::SUCCESSFULL, (string) $booking->state);
71 | }
72 |
73 | /** @test */
74 | public function test_state_is_method()
75 | {
76 | $booking = Booking::create();
77 | $this->assertTrue($booking->state->is(BookingState::PENDING));
78 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID);
79 | $this->assertTrue($booking->state->is(BookingState::SUCCESSFULL));
80 | $this->assertFalse($booking->state->is(BookingState::FAILED));
81 | }
82 |
83 | /** @test */
84 | public function test_state_is_any_of_method()
85 | {
86 | $booking = Booking::create();
87 | $this->assertTrue($booking->state->isAnyOf([
88 | BookingState::PENDING,
89 | BookingState::SUCCESSFULL,
90 | ]));
91 | $this->assertFalse($booking->state->isAnyOf([
92 | BookingState::FAILED,
93 | BookingState::SUCCESSFULL,
94 | ]));
95 | }
96 |
97 | /** @test */
98 | public function test_state_was_method()
99 | {
100 | $booking = Booking::create();
101 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID);
102 | $this->assertTrue($booking->state->was(BookingState::SUCCESSFULL));
103 | $this->assertFalse($booking->state->was(BookingState::FAILED));
104 | }
105 |
106 | /** @test */
107 | public function test_state_was_method_for_initial_state()
108 | {
109 | $booking = Booking::create();
110 | $this->assertTrue($booking->state->was(BookingState::INITIAL_STATE));
111 | $this->assertFalse($booking->state->was(BookingState::SUCCESSFULL));
112 | $this->assertFalse($booking->state->was(BookingState::FAILED));
113 | }
114 |
115 | /** @test */
116 | public function test_withCurrentState_method()
117 | {
118 | $booking = Booking::create();
119 | $state = $booking->states()->create([
120 | 'type' => 'state',
121 | 'state' => 'foo',
122 | 'from' => 'bar',
123 | 'transition' => 'bar_foo',
124 | ]);
125 |
126 | $booking = Booking::withCurrentState()->first();
127 | $this->assertTrue($booking->relationLoaded('current_state'));
128 | $this->assertSame($state->id, $booking->getRelation('current_state')->id);
129 | $this->assertSame('foo', $booking->getRelation('current_state')->state);
130 | }
131 |
132 | /** @test */
133 | public function test_loadCurrentState_method()
134 | {
135 | $booking = Booking::create();
136 | $state = $booking->states()->create([
137 | 'type' => 'state',
138 | 'state' => 'foo',
139 | 'from' => 'bar',
140 | 'transition' => 'bar_foo',
141 | ]);
142 |
143 | $this->assertFalse($booking->relationLoaded('current_state'));
144 | $booking->loadCurrentState();
145 | $this->assertTrue($booking->relationLoaded('current_state'));
146 | $this->assertSame($state->id, $booking->getRelation('current_state')->id);
147 | $this->assertSame('foo', $booking->getRelation('current_state')->state);
148 | }
149 |
150 | /** @test */
151 | public function test_getCurrentStateRelationName_method()
152 | {
153 | $booking = new Booking();
154 | $this->assertSame('current_state', $booking->getCurrentStateRelationName());
155 | $this->assertSame('current_state', $booking->getCurrentStateRelationName('state'));
156 | $this->assertSame('current_payment_state', $booking->getCurrentStateRelationName('payment_state'));
157 | }
158 |
159 | /** @test */
160 | public function test_whereStateIs_query_scope()
161 | {
162 | $booking1 = Booking::create();
163 | $booking2 = Booking::create();
164 | $booking3 = Booking::create();
165 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID);
166 |
167 | $this->assertSame(2, Booking::whereStateIs('state', BookingState::INITIAL_STATE)->count());
168 | $this->assertSame(0, Booking::whereStateIs('state', BookingState::FAILED)->count());
169 | $this->assertSame(1, Booking::whereStateIs('state', BookingState::SUCCESSFULL)->count());
170 | $this->assertSame($booking1->id, Booking::whereStateIs('state', BookingState::SUCCESSFULL)->first()->id);
171 | }
172 |
173 | /** @test */
174 | public function test_OrWhereStateIs_query_scope()
175 | {
176 | $booking1 = Booking::create();
177 | $booking2 = Booking::create();
178 | $booking3 = Booking::create();
179 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID);
180 |
181 | $this->assertSame(2, Booking::where('id', $booking2->id)->OrWhereStateIs('state', BookingState::SUCCESSFULL)->count());
182 | }
183 |
184 | /** @test */
185 | public function test_whereStateIsNot_query_scope()
186 | {
187 | $booking1 = Booking::create();
188 | $booking2 = Booking::create();
189 | $booking3 = Booking::create();
190 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID);
191 |
192 | // dd(
193 | // $booking1->state->current(),
194 | // $booking2->state->current(),
195 | // $booking3->state->current(),
196 | // Booking::whereStateIsNot('state', BookingState::INITIAL_STATE)->count()
197 | // );
198 |
199 | $this->assertSame(3, Booking::whereStateIsNot('state', BookingState::FAILED)->count());
200 | $this->assertSame(2, Booking::whereStateIsNot('state', BookingState::SUCCESSFULL)->count());
201 | $this->assertSame(0, Booking::whereStateIsNot('state', [BookingState::SUCCESSFULL, BookingState::INITIAL_STATE])->count());
202 | $this->assertSame(2, Booking::whereStateIsNot('state', [BookingState::SUCCESSFULL, BookingState::FAILED])->count());
203 | $this->assertSame(1, Booking::whereStateIsNot('state', BookingState::INITIAL_STATE)->count());
204 | $this->assertSame($booking1->id, Booking::whereStateIsNot('state', BookingState::INITIAL_STATE)->first()->id);
205 | }
206 |
207 | /** @test */
208 | public function test_orWhereStateIsNot_query_scope()
209 | {
210 | $booking1 = Booking::create();
211 | $booking2 = Booking::create();
212 | $booking3 = Booking::create();
213 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID);
214 |
215 | $this->assertSame(1, Booking::where('id', $booking1->id)->OrWhereStateIsNot('state', BookingState::INITIAL_STATE)->count());
216 | $this->assertSame(2, Booking::where('id', $booking2->id)->OrWhereStateIsNot('state', BookingState::INITIAL_STATE)->count());
217 | }
218 | }
219 |
220 | class BookingStateTransition
221 | {
222 | const PAYMENT_PAID = 'payment_paid';
223 | const PAYMENT_FAILED = 'payment_failed';
224 | }
225 |
226 | class BookingState extends State
227 | {
228 | const PENDING = 'pending';
229 | const FAILED = 'failed';
230 | const SUCCESSFULL = 'successfull';
231 |
232 | const INITIAL_STATE = self::PENDING;
233 |
234 | public static function config()
235 | {
236 | self::set(BookingStateTransition::PAYMENT_PAID)
237 | ->from(self::PENDING)
238 | ->to(self::SUCCESSFULL);
239 | self::set(BookingStateTransition::PAYMENT_FAILED)
240 | ->from(self::PENDING)
241 | ->to(self::FAILED);
242 | }
243 | }
244 |
245 | class Booking extends Model implements Stateful
246 | {
247 | use HasStates;
248 |
249 | public $timestamps = false;
250 |
251 | protected $states = [
252 | 'state' => BookingState::class,
253 | ];
254 | }
255 |
--------------------------------------------------------------------------------
/tests/StateTest.php:
--------------------------------------------------------------------------------
1 | assertEquals([
13 | 'foo', 'bar',
14 | ], DummyState::all());
15 | }
16 |
17 | public function test_uniqueTransitions_method()
18 | {
19 | $this->assertEquals([
20 | 'hello_world',
21 | ], DummyState::uniqueTransitions());
22 | }
23 |
24 | public function test_whereCan_method()
25 | {
26 | $this->assertEquals([
27 | DummyState::FOO,
28 | ], DummyState::whereCan('hello_world'));
29 | }
30 |
31 | public function test_canTransitionFrom_method()
32 | {
33 | $this->assertTrue(DummyState::canTransitionFrom('foo', 'hello_world'));
34 | $this->assertFalse(DummyState::canTransitionFrom('bar', 'hello_world'));
35 | $this->assertFalse(DummyState::canTransitionFrom('foo', 'bar'));
36 | }
37 | }
38 |
39 | class DummyState extends State
40 | {
41 | const FOO = 'foo';
42 | const BAR = 'bar';
43 |
44 | const INITIAL_STATE = self::FOO;
45 | const FINAL_STATES = [self::BAR];
46 |
47 | public static function config()
48 | {
49 | self::set('hello_world')->from(self::FOO)->to(self::BAR);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | set('database.default', 'testbench');
20 | $app['config']->set('database.connections.testbench', [
21 | 'driver' => 'sqlite',
22 | 'database' => ':memory:',
23 | 'prefix' => '',
24 | ]);
25 | }
26 |
27 | protected function getPackageProviders($app)
28 | {
29 | return [StatesServiceProvider::class];
30 | }
31 | }
32 |
--------------------------------------------------------------------------------