# laravel-model-states: give Eloquent models a state machine

> spatie/laravel-model-states adds state support to Eloquent models by combining the state pattern with state machines, so each state is its own class with its own behaviour and the allowed transitions are declared in one place. It suits a Laravel application whose records move through a lifecycle, and it asks you to be comfortable with the state pattern before you start.

**spatie/laravel-model-states** — State support for models

- Repository: https://github.com/spatie/laravel-model-states
- Website: https://spatie.be/docs/laravel-model-states
- Stars: 1,306 · Forks: 107
- Language: PHP
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/spatie-laravel-model-states

## Each state is a class, not a string

The package adds state support to models, and the README is upfront about the design. It combines concepts from the state pattern and from state machines, and it recommends that you are familiar with both patterns if you are going to use the package. That recommendation is not a formality. The whole API assumes you already think in those terms.

The example is a Payment model with three possible states, Pending, Paid and Failed. The package lets you represent each state as a separate class, handles serialization of states to the database behind the scenes, and allows for easy state transitions. The motivation in the example is simple, the colour of a payment should differ depending on its state, and a class per state is where that behaviour lives.

The model itself is a normal Eloquent model with a trait and a cast.

```php
use Spatie\ModelStates\HasStates;

class Payment extends Model
{
    use HasStates;

    protected $casts = [
        'state' => PaymentState::class,
    ];
}
```

The cast is the mechanism. A normal string column is cast to a state object, so the state is a first class value in your code rather than a string you compare against. The consequence is that the state can carry behaviour, and the database column does not have to change at all.

The abstract parent class holds the transition rules, which is the state machine half of the package.

```php
use Spatie\ModelStates\State;
use Spatie\ModelStates\StateConfig;

abstract class PaymentState extends State
{
    abstract public function color(): string;
    
    public static function config(): StateConfig
    {
        return parent::config()
            ->default(Pending::class)
            ->allowTransition(Pending::class, Paid::class)
            ->allowTransition(Pending::class, Failed::class)
        ;
    }
}
```

A default state, and the list of transitions that are permitted. A concrete state then only has to implement its own behaviour.

```php
class Paid extends PaymentState
{
    public function color(): string
    {
        return 'green';
    }
}
```

And the usage reads as if the state were an object, not a column.

```php
$payment = Payment::find(1);

$payment->state->transitionTo(Paid::class);

echo $payment->state->color();
```

## Installing the package and publishing the config

The package is a normal Composer dependency.

```bash
composer require spatie/laravel-model-states
```

There is one configuration file, and it is published with an Artisan command.

```bash
php artisan vendor:publish --provider="Spatie\ModelStates\ModelStatesServiceProvider" --tag="model-states-config"
```

The published file is short, and its single key is worth reading.

```php
return [

    /*
     * The fully qualified class name of the default transition.
     */
    'default_transition' => Spatie\ModelStates\DefaultTransition::class,

];
```

The key sets the default transition class, which is the strategy used when a model is created without an explicit state. That is a small design decision with large consequences, and the fact that it is a single configurable class rather than a boolean flag means you can replace the whole behaviour of what happens on creation, for example to pick a default state based on the model's other attributes. The name DefaultTransition suggests that the package treats this as a strategy pattern point, and the config is where you swap it.

The full usage documentation is not in the README. The README points at the docs site for the version 2 introduction, which is the versioned documentation for the current major line. The repository also carries a docs/ directory, a config/ directory, a CHANGELOG.md, an UPGRADING.md for version transitions, a phpunit.xml.dist, and separate CI workflows for running the tests and for checking and fixing styling with php-cs-fixer.

## Transitions are declared in one place and enforced at runtime

The part that makes this more than a typed enum is the allowTransition calls on the StateConfig. A transition that is not declared is not available, so the set of legal moves is a property of the model rather than a convention.

The design has a consequence that is worth being honest about, because it is the main trade-off. The rules live in PHP, in a static config method on the abstract state class. That means they are checked at runtime, when a transition is attempted, and not at compile time and not by the type system. A transition from Paid to Pending that was never allowed will not compile any differently from one that was allowed; it will fail when the code path runs. For most applications that is the right place for the check, since the legal transitions often depend on data that only exists at runtime. But it does mean the compiler is not helping you here, and a test that exercises every declared transition is what turns the state machine from documentation into a guarantee.

The state pattern side is what lets each state class hold behaviour. The abstract color() method in the example is a placeholder for whatever you want a state to know about itself, and a payment state class is a natural place for things like whether it is terminal, what transitions it should offer in a UI, or how it should be labelled. Because the cast returns a state instance, that behaviour is available anywhere the model is, including in a Blade view or a JSON API resource.

The README notes that the package handles serialization of states to the database behind the scenes, and that the default state is applied through the default transition. Both of those are worth checking against your own model, in particular if your state column already holds values that are not class names, or if you need a state that is not one of the declared classes.

## Where laravel-model-states is the wrong tool

Three cases argue against it. The first is a field that has two values and no lifecycle. A boolean column called paid, or a status column with two options, does not need a class per state and a transition table. The ceremony of an abstract parent, three subclasses and a config method is real, and if the state cannot change in interesting ways the package reduces to an enum with extra steps.

The second case is a team that has not worked with the state pattern. The README says plainly that it is recommended you are familiar with both the state pattern and state machines, and the API will make sense to you only if you do. The names in the API, State, StateConfig, DefaultTransition, transitionTo, are pattern vocabulary, and a team meeting them for the first time will spend its first day reading rather than building.

The third case is a workflow whose legal transitions depend on things outside the model. The configuration is static, declared once on the abstract class, and the same rules apply to every instance of the model. If one order can go from Pending to Paid but another cannot, because the amount or the customer tier forbids it, that rule does not fit this package and belongs in a domain service that checks before calling transitionTo.

There is a broader point about what this package does and does not give you. It gives you typed states, declared transitions, and a cast. It does not give you a state history, a trail of who changed a state and when, a UI for choosing transitions, or anything about persistence of the transition log. If the audit trail matters, you need to record it yourself in the transition code path, and the readme says nothing about a hook for that, so it is a question to ask rather than a feature to rely on.

On maintenance, the release history shows 2.14.0 in April 2026, 2.14.1 later that month and 2.14.2 on 2026-07-22, and the last push was on the same day. The 2.x line has been stable for a while, the package is MIT licensed, and the documentation is versioned, with the introduction page for version 2 linked from the README.

## Conclusion

laravel-model-states suits a Laravel team whose models have a real lifecycle, such as payments, orders or subscriptions, and who want the transition rules to live in code rather than in scattered conditionals. It does not suit a model with two values that behaves fine as a boolean, since a class per state is a lot of ceremony for that, and it does not suit a team that has not met the state pattern. Before you adopt it, read the version 2 introduction in the documentation, and note that the last push was on 2026-07-22 with the newest release 2.14.2 from the same day.

## FAQ

### How do I install laravel-model-states?

Install it with composer require spatie/laravel-model-states, then publish the config with php artisan vendor:publish --provider="Spatie\ModelStates\ModelStatesServiceProvider" --tag="model-states-config".

### How are state transitions defined?

In a static config method on the abstract state class, using a StateConfig built from parent::config(), where default() sets the initial state and allowTransition() declares each permitted move, for example from Pending to Paid and from Pending to Failed.

### Do I need to know the state pattern to use this package?

The README recommends that you are familiar with both the state pattern and state machines if you are going to use the package, since it combines concepts from both.

### What does the published config file control?

It has a single key, default_transition, set to Spatie\ModelStates\DefaultTransition::class, which is the fully qualified class name of the default transition used when a model is created without an explicit state.

## Sources

- [License: MIT](https://github.com/spatie/laravel-model-states/blob/main/LICENSE)
- [Project website](https://spatie.be/docs/laravel-model-states)
- [README](https://github.com/spatie/laravel-model-states/blob/main/README.md)
- [Releases](https://github.com/spatie/laravel-model-states/releases)
- [spatie/laravel-model-states on GitHub](https://github.com/spatie/laravel-model-states)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/spatie-laravel-model-states
