Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take QuizThe state pattern is one of the best ways to add state-specific behavior to models while keeping them clean. If you have ever written a chain of if ($course->status === 'draft') checks scattered across your codebase, this chapter will change how you think about model states.
The problem with conditionals
A course can be in draft or published. Depending on the state, it behaves differently: a draft course shows a blue badge, a published one shows green. The naive approach:
class Course extends Model
{
public function getStateColor(): string
{
if ($this->state === CourseState::Draft) {
return 'blue';
}
if ($this->state === CourseState::Published) {
return 'green';
}
return 'grey';
}
}
This is a simplified example. In real projects, state-dependent behavior goes far beyond colors. Can this course be published? Can it be edited? Does it require review? Should it send enrollment notifications? Each question adds another if/else block, and these blocks are scattered across models, controllers, views, and jobs.
You could move the conditionals into an enum class:
enum CourseState: string
{
case Draft = 'draft';
case Published = 'published';
public function color(): string
{
return match($this) {
self::Draft => 'blue',
self::Published => 'green',
};
}
}
But this is still a big conditional in disguise, regardless of the syntactic sugar. Every time you add a new state, you modify the existing class. Every method that depends on state gets another case.
The state pattern
The state pattern turns this approach upside down. Instead of one class with conditionals, each state becomes its own class:
abstract class CourseState
{
public function __construct(protected Course $course) {}
abstract public function color(): string;
abstract public function canEnroll(): bool;
}
Each concrete state implements the abstract methods:
class DraftCourseState extends CourseState
{
public function color(): string
{
return 'blue';
}
public function canEnroll(): bool
{
return false;
}
}
class PublishedCourseState extends CourseState
{
public function color(): string
{
return 'green';
}
public function canEnroll(): bool
{
return $this->course->lessons()->count() > 0
&& $this->course->format->acceptsEnrollments();
}
}
The model stores the concrete state class name in the database and resolves it:
use Illuminate\Database\Eloquent\Casts\Attribute;
class Course extends Model
{
protected function state(): Attribute
{
return Attribute::make(
get: fn (): CourseState => new $this->state_class($this),
);
}
public function canEnroll(): bool
{
return $this->state->canEnroll();
}
}
Why this is better
Each state is independently testable. You do not need to set up complex scenarios to test one specific behavior:
it('returns blue for draft state', function (): void {
$course = Course::factory()->create();
$state = new DraftCourseState($course);
expect($state->color())->toBe('blue');
});
Adding new states is safe. When you add an ArchivedCourseState, you create a new class. You do not modify existing state classes, so you cannot accidentally break existing behavior.
Complex logic stays contained. The canEnroll method on PublishedCourseState can contain complex business rules that only apply to published courses. Those rules do not pollute other states.
The model stays lean. It delegates to the state object instead of containing the logic itself.
In practice, you do not need to build all of this from scratch. The next section introduces spatie/laravel-model-states, which provides the state pattern, transition management, query scopes, and events out of the box.
Transitions
States tell you how a model behaves right now. Transitions control how it moves from one state to another. Can a draft course be published? Can an archived course go back to draft? What must happen when a transition occurs?
Transitions vs actions
Transitions look similar to actions at first glance. Both encapsulate a unit of work. The difference is scope:
- Actions represent broad business operations.
PublishCourseActionmight validate input, create lesson records, calculate durations, generate a certificate template, and send notifications. It is a full user story. - Transitions represent a single state change.
DraftToPublishedTransitionvalidates that the move is legal, changes the state column, and fires side effects tied specifically to that state change — a log entry, a timestamp, maybe a notification.
An action might use a transition internally. PublishCourseAction does its work, then calls a draft-to-published transition to flip the state. The action owns the business operation. The transition owns the state change.
Without explicit transitions, state changes happen ad hoc. A controller sets state here, a job sets it there, and nobody enforces which moves are valid. Transitions centralize this, making it impossible to accidentally skip validation or forget a side effect.
Using spatie/laravel-model-states
Building states and transitions from scratch works for learning, but in production spatie/laravel-model-states handles the boilerplate. It provides a State base class, a HasStates trait, transition configuration, automatic serialization, query scopes, and events.
composer require spatie/laravel-model-states
Defining states
Each state extends the package's State class. The abstract base declares which transitions are allowed and which state is the default, using PHP 8 attributes:
namespace Domain\Courses\States;
use Spatie\ModelStates\State;
use Spatie\ModelStates\Attributes\AllowTransition;
use Spatie\ModelStates\Attributes\DefaultState;
#[
DefaultState(DraftCourseState::class),
AllowTransition(DraftCourseState::class, PublishedCourseState::class),
AllowTransition(PublishedCourseState::class, ArchivedCourseState::class),
]
abstract class CourseState extends State
{
abstract public function color(): string;
abstract public function canEnroll(): bool;
protected function course(): Course
{
return $this->getModel();
}
}
The course() helper wraps the package's getModel() method so concrete states can reference $this->course() instead of calling $this->getModel() everywhere.
Concrete states implement the abstract methods:
class DraftCourseState extends CourseState
{
public function color(): string
{
return 'blue';
}
public function canEnroll(): bool
{
return false;
}
}
class PublishedCourseState extends CourseState
{
public function color(): string
{
return 'green';
}
public function canEnroll(): bool
{
return $this->course()->lessons()->count() > 0
&& $this->course()->format->acceptsEnrollments();
}
}
Registering states on the model
Add the HasStates trait and cast the state column:
use Spatie\ModelStates\HasStates;
class Course extends Model
{
use HasStates;
protected function casts(): array
{
return [
'state' => CourseState::class,
];
}
}
The database column is a simple string:
$table->string('state');
The package handles serialization automatically. You access state behavior directly on the model:
$course->state->color(); // 'blue'
$course->state->canEnroll(); // false
Transitioning states
Trigger a transition with transitionTo:
$course->state->transitionTo(PublishedCourseState::class);
If the transition is not configured — for example, jumping from draft directly to archived — the package throws a TransitionNotFound exception. Invalid state changes are impossible by default.
Custom transition classes
Simple transitions just flip the state. For transitions that need validation or side effects, create a custom transition class that extends the package's Transition base:
namespace Domain\Courses\Transitions;
use Domain\Courses\Models\Course;
use Domain\Courses\States\PublishedCourseState;
use Spatie\ModelStates\Transition;
class DraftToPublishedTransition extends Transition
{
public function __construct(
private Course $course,
) {}
public function handle(): Course
{
if ($this->course->lessons()->count() === 0) {
throw new InvalidTransitionException(
'A course must have at least one lesson before publishing.'
);
}
$this->course->state = new PublishedCourseState($this->course);
$this->course->published_at = now();
$this->course->save();
History::log($this->course, 'Draft to Published');
return $this->course;
}
}
Register it by passing the class as the third argument to AllowTransition:
#[
DefaultState(DraftCourseState::class),
AllowTransition(DraftCourseState::class, PublishedCourseState::class, DraftToPublishedTransition::class),
AllowTransition(PublishedCourseState::class, ArchivedCourseState::class),
]
abstract class CourseState extends State
When you call transitionTo, the package routes through your custom class automatically:
// This goes through DraftToPublishedTransition::handle()
$course->state->transitionTo(PublishedCourseState::class);
Where everything lives
States and transitions live in the domain layer alongside the model they belong to:
Domain/Courses/
├── Models/
│ └── Course.php
├── States/
│ ├── CourseState.php
│ ├── DraftCourseState.php
│ ├── PublishedCourseState.php
│ └── ArchivedCourseState.php
└── Transitions/
└── DraftToPublishedTransition.php
Testing transitions
it('transitions from draft to published', function (): void {
$course = Course::factory()
->has(Lesson::factory())
->create();
$course->state->transitionTo(PublishedCourseState::class);
expect($course->state)
->toBeInstanceOf(PublishedCourseState::class);
});
it('prevents publishing without lessons', function (): void {
$course = Course::factory()->create();
$course->state->transitionTo(PublishedCourseState::class);
})->throws(InvalidTransitionException::class);
it('prevents invalid state transitions', function (): void {
$course = Course::factory()->create(); // draft by default
$course->state->transitionTo(ArchivedCourseState::class);
})->throws(TransitionNotFound::class);
Why use the package
- Transition rules are declared once on the abstract state class, not scattered across controllers and jobs
- Invalid transitions are rejected automatically — you cannot accidentally move a course from draft to archived if that path is not configured
- Query scopes let you filter models by state:
Course::whereState('state', PublishedCourseState::class)->get() - Events fire automatically on every successful transition (
StateChanged), which you can hook into for logging, notifications, or cache invalidation - Custom transitions let you add validation and side effects while the package handles the routing
States without transitions
An important insight: states do not require transitions. A state that never changes still benefits from the pattern.
Look at this check inside PublishedCourseState:
public function canEnroll(): bool
{
return $this->course->lessons()->count() > 0
&& $this->course->format === CourseFormat::SelfPaced;
}
That $this->course->format === CourseFormat::SelfPaced is a hidden conditional. Course format is itself a state — one that never changes for a given course, but still determines behavior.
Apply the state pattern to it too:
abstract class CourseFormat
{
public function __construct(protected Course $course) {}
abstract public function acceptsEnrollments(): bool;
}
class SelfPacedFormat extends CourseFormat
{
public function acceptsEnrollments(): bool
{
return true;
}
}
class InstructorLedFormat extends CourseFormat
{
public function acceptsEnrollments(): bool
{
return $this->course->starts_at->isFuture();
}
}
Now the published state becomes cleaner:
class PublishedCourseState extends CourseState
{
public function canEnroll(): bool
{
return $this->course->lessons()->count() > 0
&& $this->course->format->acceptsEnrollments();
}
}
No conditionals. Just polymorphism. The code reads linearly, which makes it easier to reason about.
Enums vs states
With PHP 8.1's native enums, the question arises: when should you use enums and when the state pattern?
The answer depends on how much behavior is attached to the value.
Use enums when you have a set of related values with minimal behavior. A difficulty level (Beginner, Intermediate, Advanced) might just need a label and an icon. A few simple match expressions are perfectly fine:
enum DifficultyLevel: string
{
case Beginner = 'beginner';
case Intermediate = 'intermediate';
case Advanced = 'advanced';
public function label(): string
{
return match($this) {
self::Beginner => 'Beginner',
self::Intermediate => 'Intermediate',
self::Advanced => 'Advanced',
};
}
}
Use the state pattern when the value significantly changes how the application behaves. A course lifecycle (draft, published, archived, suspended) affects permissions, calculations, notifications, and UI rendering. The conditional logic would grow unmanageable in an enum.
There is no hard rule. If you start with an enum and find yourself adding more and more match expressions with growing complexity, it is time to refactor to the state pattern.
The state pattern is something you can introduce incrementally. Start with the states that cause the most complex conditional chains, and migrate others as needed. You do not have to convert everything at once.
Summary
- Scattered
if ($model->status === '...')conditionals become unmaintainable as states grow. Each new state means modifying every conditional block across the codebase. - The state pattern replaces conditionals with polymorphism. Each state becomes its own class with an abstract base. Adding a new state means adding a new class, not modifying existing ones.
- Transitions control how a model moves from one state to another. They differ from actions in scope: an action represents a broad business operation, while a transition owns a single state change — its validation, persistence, and side effects. An action might use a transition internally.
spatie/laravel-model-statesprovides the state pattern out of the box: aStatebase class, aHasStatestrait, transition configuration via PHP 8 attributes (#[AllowTransition],#[DefaultState]), automatic serialization, query scopes, and events. Use custom transition classes for transitions that need validation or side effects.- States and transitions live in the domain layer alongside their model —
Domain/Courses/States/for state classes andDomain/Courses/Transitions/for transition classes. - States without transitions are still valuable. A course format (self-paced vs instructor-led) never changes, but it still determines behavior. Apply the pattern to eliminate hidden conditionals.
- Use enums for simple sets of values with minimal behavior (difficulty levels, categories). Use the state pattern when the value significantly changes how the application behaves (course lifecycle, order status).
- Introduce the state pattern incrementally — start with the states that cause the most complex conditional chains, and migrate others as the benefit becomes clear.
References
- State Pattern — Refactoring Guru
- spatie/laravel-model-states — Spatie Documentation
- PHP 8.1: Enums — Brent Roose
- Replace Conditional with Polymorphism — Refactoring Guru
- The State Pattern — Clean Code in Laravel
- Enums, Value Objects, and Type Safety — Clean Code in Laravel
Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take Quiz