nathandunn/laravel-state-history

Laravel package for managing enum-based model states with enforced transitions and automatic status history tracking.

v1.0.2 2025-08-25 07:34 UTC

This package is auto-updated.

Last update: 2025-08-25 07:37:01 UTC


README

A Laravel package for managing enum-based model states with enforced transitions and automatic history tracking.

Features

  • 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

Installation

composer require nathandunn/laravel-state-history
php artisan vendor:publish --provider="NathanDunn\StateHistory\StateHistoryServiceProvider" --tag="migrations"
php artisan migrate

Quick Start

1. Define States

enum ArticleState: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Archived = 'archived';
}

2. Create a State Machine

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);
    }
}

3. Configure Your Model

use NathanDunn\StateHistory\Traits\HasState;

class Article extends Model
{
    use HasState;

    protected $casts = [
        'state' => ArticleState::class,
    ];

    protected function stateMachine(): array
    {
        return ['state' => ArticleStateMachine::class];
    }
}

Usage

Transitions

$article = Article::create(['state' => ArticleState::Draft]);

$article->transitionTo('state', ArticleState::Published, meta: [
    'editor' => 'alice'
]);

Querying

$published = Article::whereState('state', ArticleState::Published)->get();

if ($article->isInState('state', ArticleState::Published)) {
    // published
}

$allowed = $article->getAllowedTransitions('state');

Current State

$state = $article->getState('state');     // ArticleState::Published
$raw   = $article->getCurrentState('state'); // "published"

History

$history = $article->states('state');

foreach ($history as $h) {
    $from = $h->from; // Enum instance
    $to   = $h->to;
    $meta = $h->meta;
}

Advanced

Multiple State Fields

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,
        ];
    }
}

Guards

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;
    }
}

Effects

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()]);
        }
    }
}

Events

  • StateTransitioning – fired before a transition
  • StateTransitioned – fired after success
use NathanDunn\StateHistory\Events\StateTransitioned;

Event::listen(StateTransitioned::class, function ($event) {
    Log::info("Model {$event->model->id} {$event->from}{$event->to}");
});

Current State Columns

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,
];

State Casting

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.