spatie/laravel-model-states
Add state and state machine behavior to Laravel Eloquent models. Represent each state as a class, automatically serialize to/from the database, and perform clean, explicit transitions with configurable rules—ideal for workflows like payments, orders, and approvals.
Installation:
composer require spatie/laravel-model-states
Database Migration:
Add a state column to your model table (e.g., payments):
Schema::table('payments', function (Blueprint $table) {
$table->string('state')->nullable();
});
Model Integration:
Use the HasStates trait and cast the state field:
use Spatie\ModelStates\HasStates;
class Payment extends Model
{
use HasStates;
protected $casts = [
'state' => PaymentState::class,
];
}
Define Abstract State Class:
Create an abstract state class (e.g., PaymentState) extending Spatie\ModelStates\State:
abstract class PaymentState extends State
{
public static function config(): StateConfig
{
return parent::config()
->default(Pending::class);
}
}
Create Concrete State Classes:
Implement concrete states (e.g., Pending, Paid):
class Pending extends PaymentState {}
class Paid extends PaymentState {}
First Transition:
$payment = Payment::find(1);
$payment->state->transitionTo(Paid::class);
Use states to dynamically render UI elements (e.g., buttons, labels) based on the model's state:
@if($payment->state instanceof Paid)
<span class="text-green-500">Paid</span>
@else
<span class="text-yellow-500">Pending</span>
@endif
Define Transitions: Configure allowed transitions in the abstract state class:
public static function config(): StateConfig
{
return parent::config()
->allowTransition(Pending::class, Paid::class)
->allowTransition(Pending::class, Failed::class);
}
Trigger Transitions:
Use transitionTo() in controllers or services:
$payment->state->transitionTo(Paid::class);
Handle Transition Logic: Override methods in concrete states for side effects:
class Paid extends PaymentState
{
public function transitionToPaid(): void
{
$this->model->update(['paid_at' => now()]);
}
}
Event-Driven Architecture:
Listen to StateChanged events to trigger actions (e.g., notifications, logs):
use Spatie\ModelStates\Events\StateChanged;
StateChanged::listen(function (StateChanged $event) {
Log::info("State changed from {$event->previousState} to {$event->newState}");
});
State-Driven Validation: Validate transitions or state-specific fields:
public function rules()
{
return [
'amount' => ['required', Rule::requiredUnless(fn () => $this->state instanceof Paid)],
];
}
API Responses: Serialize state-specific data in API responses:
return PaymentResource::make($payment)->additional([
'state_data' => $payment->state->getMetadata(),
]);
State Machines for Complex Workflows: Use nested states or composite patterns for multi-step processes (e.g., order fulfillment):
class OrderState extends State
{
public static function config(): StateConfig
{
return parent::config()
->allowTransition(Processing::class, Shipped::class)
->allowTransition(Shipped::class, Delivered::class);
}
}
Testing States: Mock state transitions in tests:
$payment = Payment::factory()->create();
$this->assertInstanceOf(Pending::class, $payment->state);
$payment->state->transitionTo(Paid::class);
$this->assertInstanceOf(Paid::class, $payment->state);
Circular Dependencies:
Avoid circular references in state transitions (e.g., A → B → A). Use allowTransition() to enforce one-way flows.
Database Serialization:
Ensure state classes are autoloadable. Place them in a single directory (e.g., app/States/Payment) to avoid resolution issues:
app/
States/
Payment/
Pending.php
Paid.php
PaymentState.php
State Name Conflicts:
Use static $name properties for custom state names (e.g., public static $name = 'paid'). Avoid hyphens (-) to prevent conflicts with Laravel’s internal naming.
Default State Overrides:
If no default state is set, the model’s state will be null. Always define a default in StateConfig:
->default(Pending::class)
Mass Assignment Risks:
Prevent mass assignment of state fields in fillable or guarded arrays to avoid unauthorized state changes:
protected $guarded = ['state'];
Transition Errors:
Use try-catch to handle invalid transitions gracefully:
try {
$payment->state->transitionTo(InvalidState::class);
} catch (InvalidTransition $e) {
Log::error("Invalid transition: {$e->getMessage()}");
}
State Resolution Issues: Verify state classes are registered and autoloaded. Run:
composer dump-autoload
Event Listener Conflicts:
Ensure custom StateChanged events are properly bound:
Event::listen(StateChanged::class, function ($event) {
// Custom logic
});
Custom State Metadata:
Store additional state-specific data using getMetadata():
class Paid extends PaymentState
{
public function getMetadata(): array
{
return ['transaction_id' => $this->model->transaction_id];
}
}
State-Specific Queries:
Filter models by state using whereState():
$paidPayments = Payment::whereState(Paid::class)->get();
State Validation: Add state-specific validation rules:
class Paid extends PaymentState
{
public function validate(): void
{
if ($this->model->amount <= 0) {
throw new InvalidStateTransition("Amount must be positive.");
}
}
}
State Transitions with Callbacks:
Use transitionTo() with callbacks for async operations:
$payment->state->transitionTo(Paid::class, function () {
dispatch(new ProcessPayment($payment));
});
State-Specific API Resources: Dynamically transform state data in API responses:
public function toArray($request)
{
return array_merge(parent::toArray($request), [
'state_color' => $this->state->color(),
]);
}
Eager Load States: Avoid N+1 queries by eager loading state-dependent relationships:
$payments = Payment::with(['state' => function ($query) {
$query->select('state');
}])->get();
Cache State Configurations:
Cache the StateConfig if performance is critical (e.g., in high-traffic apps):
public static function config(): StateConfig
{
static $config;
return $config ??= parent::config()
->default(Pending::class)
->allowTransition(Pending::class, Paid::class);
}
Batch State Updates:
Use update() with a closure for bulk state transitions:
Payment::where('user_id', $userId)
->update(['state' => Paid::class]);
How can I help you explore Laravel packages today?