A Laravel package for managing enum-based model states with enforced transitions and automatic history tracking.
- Native PHP Enums (PHP 8.2+)
- Enforced Transitions with a pluggable state machine
- Automatic History with metadata
- Smart Casting of historical
from
/to
values (enums, dates, primitives, custom casts) - Atomic Operations โ state change + history in one transaction
- Current State Columns (
current_{field}
) for indexing & querying - Events, Guards & Effects for lifecycle hooks
- Laravel 11โ12 Support
composer require nathandunn/laravel-state-history
php artisan vendor:publish --provider="NathanDunn\StateHistory\StateHistoryServiceProvider" --tag="migrations"
php artisan migrate
enum ArticleState: string
{
case Draft = 'draft';
case Published = 'published';
case Archived = 'archived';
}
use NathanDunn\StateHistory\Contracts\StateMachine;
use NathanDunn\StateHistory\TransitionMap;
class ArticleStateMachine implements StateMachine
{
public function getTransitions(): TransitionMap
{
return TransitionMap::build(ArticleState::class)
->allowFromNull(ArticleState::Draft)
->allow(ArticleState::Draft, ArticleState::Published)
->allow(ArticleState::Published, ArticleState::Archived)
->allowAnyTo(ArticleState::Draft);
}
}
use NathanDunn\StateHistory\Traits\HasState;
class Article extends Model
{
use HasState;
protected $casts = [
'state' => ArticleState::class,
];
protected function stateMachine(): array
{
return ['state' => ArticleStateMachine::class];
}
}
$article = Article::create(['state' => ArticleState::Draft]);
$article->transitionTo('state', ArticleState::Published, meta: [
'editor' => 'alice'
]);
$published = Article::whereState('state', ArticleState::Published)->get();
if ($article->isInState('state', ArticleState::Published)) {
// published
}
$allowed = $article->getAllowedTransitions('state');
$state = $article->getState('state'); // ArticleState::Published
$raw = $article->getCurrentState('state'); // "published"
$history = $article->states('state');
foreach ($history as $h) {
$from = $h->from; // Enum instance
$to = $h->to;
$meta = $h->meta;
}
class Order extends Model
{
use HasState;
protected $casts = [
'status' => OrderStatus::class,
'payment_status' => PaymentStatus::class,
];
protected function stateMachine(): array
{
return [
'status' => OrderStateMachine::class,
'payment_status' => PaymentStateMachine::class,
];
}
}
use NathanDunn\StateHistory\Contracts\Guard;
class PublishedArticleGuard implements Guard
{
public function allows($model, $from, $to): bool
{
if ($to === ArticleState::Archived &&
$model->published_at < now()->subDays(30)) {
throw new \Exception('Must be published 30 days before archiving');
}
return true;
}
}
use NathanDunn\StateHistory\Contracts\Effect;
class PublishEffect implements Effect
{
public function execute($model, $from, $to): void
{
if ($to === ArticleState::Published) {
$model->update(['published_at' => now()]);
}
}
}
StateTransitioning
โ fired before a transitionStateTransitioned
โ fired after success
use NathanDunn\StateHistory\Events\StateTransitioned;
Event::listen(StateTransitioned::class, function ($event) {
Log::info("Model {$event->model->id} {$event->from} โ {$event->to}");
});
Optional current_{field}
columns improve indexing & analytics.
Schema::table('articles', function (Blueprint $t) {
$t->string('current_state')->nullable()->index();
});
Config (config/state-history.php
):
return [
'use_current_columns' => true,
'prefix' => 'current_',
'model' => \App\Models\CustomStateHistory::class,
];
History values auto-cast to configured types:
foreach ($article->states('state') as $h) {
$from = $h->from; // Enum
$to = $h->to;
}
Supports: enums, dates, primitives, custom casts.