rizalsaja / laravel-status-transition
A simple and flexible trait to add state machine behaviour to Laravel Eloquent models, with transition validation and automatic history tracking.
Package info
github.com/RizalAnas00/laravel-status-transition
pkg:composer/rizalsaja/laravel-status-transition
Requires
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0
README
A simple and flexible trait to add state machine behaviour to Laravel Eloquent models, with transition validation and automatic history tracking.
Features
- Attach status state machine to any Eloquent model via a single trait
- Define allowed statuses and enforce valid transition paths
- Automatic status history recording with reason and actor tracking
- Polymorphic history — one
status_historiestable for all models - Query scopes for filtering by status
- Configurable: disable history recording globally via config
- Auto-discovery support — no manual provider registration needed
Requirements
| Package version | Laravel | PHP |
|---|---|---|
| 1.x | 10, 11, 12 | 8.1+ |
Installation
Install via Composer:
composer require rizalsaja/laravel-status-transition
The service provider is auto-discovered. No manual registration needed.
Publish the config file:
php artisan vendor:publish --tag=laravel-status-transition-config
Publish and run the migrations:
php artisan vendor:publish --tag=laravel-status-transition-migrations php artisan migrate
On-Progress Features
Warning
The following features are available on the 1.1 branch and are part of the upcoming v1.1.0 milestone. They are not yet stable and the API may change before the final release. Use at your own risk.
Before and/or After Hooks for Status Transitions
Attach before and/or after callbacks to specific transitions directly in your model's $transitions map.
protected $transitions = [ 'pending' => [ 'processing' => [ 'before' => 'validateStock', // method name 'after' => 'sendProcessingEmail', // method name ], 'cancelled', // no hooks needed — plain string is fine ], ]; public function validateStock(): void { // runs before the status is saved } public function sendProcessingEmail(): void { // runs after the status is saved }
Closures are also supported:
'cancelled' => [ 'after' => function ($model) { Log::info("Order {$model->id} was cancelled."); }, ],
Usage
1. Add the trait to your model
use Rizalsaja\LaravelStatusTransition\Traits\HasStatus; class Order extends Model { use HasStatus; /** * All valid statuses for this model. */ protected $statuses = [ 'pending', 'processing', 'shipped', 'delivered', 'cancelled', ]; /** * Allowed transition map. * Omit this property to allow all transitions freely. */ protected $transitions = [ 'pending' => ['processing', 'cancelled'], 'processing' => ['shipped', 'cancelled'], 'shipped' => ['delivered'], 'delivered' => [], 'cancelled' => [], ]; }
Make sure your model's table has a status column:
$table->string('status')->default('pending');
2. Transition to a new status
$order = Order::create(['title' => 'New Order']); // Simple transition $order->transitionTo('processing'); // With a reason $order->transitionTo('cancelled', reason: 'Customer requested cancellation');
3. Check current status
$order->getCurrentStatus(); // 'processing' $order->isStatus('processing'); // true $order->isNotStatus('shipped'); // true $order->canTransitionTo('shipped'); // true $order->availableTransitions(); // ['shipped', 'cancelled']
4. Query by status
Order::whereStatus('pending')->get(); Order::whereNotStatus('cancelled')->get(); Order::whereStatusIn(['pending', 'processing'])->get();
5. Access history
// All history records (ordered by latest inserted) $order->statusHistory; // Most recent record only $order->latestStatus; // History fields $history->from; // 'pending' $history->to; // 'processing' $history->reason; // 'Payment confirmed' $history->changed_by; // user id (nullable) $history->created_at;
6. Resolve back to the model
$history = $order->statusHistory->first(); $history->statusable; // returns the Order instance
Configuration
After publishing, edit config/laravel-status-transition.php:
return [ /* * Default statuses if the model does not define its own $statuses property. */ 'default_statuses' => ['active', 'inactive'], /* * Set to false to disable status history recording entirely. */ 'record_history' => true, ];
Customisation
Custom status column
// default: 'status' protected $statusColumn = 'state';
Custom initial status
// default: first item in $statuses protected $initialStatus = 'draft';
Allow all transitions freely
Omit $transitions from your model. Without it, any status can transition to any other status defined in $statuses.
Error Handling
use Rizalsaja\LaravelStatusTransition\Exceptions\InvalidStatusTransitionException; try { $order->transitionTo('shipped'); // invalid from 'pending' } catch (InvalidStatusTransitionException $e) { // "Cannot transition from [pending] to [shipped]. Allowed transitions: [processing, cancelled]." report($e); } try { $order->transitionTo('unknown'); } catch (\InvalidArgumentException $e) { // "Status [unknown] is not a valid status." report($e); }
Testing
vendor/bin/phpunit --testdox
Changelog
Please see CHANGELOG.md for recent changes.
Contributing
Please see CONTRIBUTING.md for details.
License
The MIT License (MIT). Please see LICENSE.md for more information.