Introduction

Often times you will need to change the state of your model and perform different actions based on the current state of the model. For example, my blog currently doesnt support drafting, publishing or any of those for the post model. Hence everything I write, is available to public as soon as I save it.

What I want is to have a state field in my posts table and to publicize the posts based on the state of the post. For now, I just want 2 states: publish and draft and nothing more. But down the line, I may need to schedule the posts too, but lets keep that for another day. So lets get started!

Migration

First of all I would need to add the state field to migrations. I can use a modification migration or just modify the current posts table migration and run a migration fresh command. Since I have only one more post, I can take the backup very easily. So ill go with that option instead of a modification migration. So in my posts table migration I am going to add one more field.

Schema::create('posts', function (Blueprint $table) {
            // migration columns
            $table->string('state');
        });

Note: If you are adding a modification migration, you may need to add a default value or make the state nullable.

Now I need to add spatie/laravel-model-states. Run the following command to install the package

composer require spatie/laravel-model-states

Now add the necessary traits and changes to the Post model.

namespace App\Models;

use App\States\Post\PostState;
use Illuminate\Database\Eloquent\Model;
use Spatie\ModelStates\HasStates;

class Post extends Model
{
    use HasFactory;
    use HasStates;

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

   // the rest of the model code

Did you notice that I have a PostState class imported? Don't worry, we are going to create that now. This will be the abstract class that is going to control all the Post states. I am going to put these in a seperate folder under apps directroy. I am also going to add one more folder for the Post model as I know I will soon need to add states for other models too. So now I have created the following file in app/States/Post directory and called it PostState.php

<?php

namespace App\States\Post;

use Spatie\ModelStates\Exceptions\InvalidConfig;
use Spatie\ModelStates\State;
use Spatie\ModelStates\StateConfig;

abstract class PostState extends State
{
    /**
     * @return StateConfig
     * @throws InvalidConfig
     */
    public static function config(): StateConfig
    {
        return parent::config()
            ->default(Draft::class);
    }
}

Once again, we are setting a default state as Draft::class. This is the state the model will be saved by default. You will not have to provide any value for the state when saving the model (in this case a post). As you konw a draft class does not exist at this point. So lets create that too.

<?php

namespace App\States\Post;

class Draft extends PostState
{
       protected static $name = 'draft';
}

This Draft.php file is also created within the app/States/Post directoy. I have also added a static variable of name, this as per the documentation will override the default way of saving the whole namespace of the class (eg: App\States\Post\Draft) to just given name in the variable. (eg: 'draft'). This seems very reasonable change as I might want to move the namespace of the states to somewhere else and saving the whole namespace of a class into the database will not be very scalable, in my humble opionion. You are free to do whatever you want.

Lets add one more state of published.

namespace App\States\Post;

class Published extends PostState
{
       protected static $name = 'published';
}

Now lets add the allowed transitions to PostState abstract class we created earlier. I want the following to happen

  • Save the posts in draft state by default (already implemented)
  • Allow posts to be published if it is in draft state
  • Allow posts to be drafted if it is in published state So the config function of PostState class will be updated as below
   public static function config(): StateConfig
    {
        return parent::config()
            ->default(Draft::class) // default saving state
            ->allowTransition(Draft::class, Published::class) // allows transition from draft to published
            ->allowTransition(Published::class, Draft::class); // allows transition from published to draft
    }

Views

As I am going to save the posts in draft format, I want to be able to change its state by visiting to its show view.

Route::get('posts/{post}', [PostsController::class, 'show'])->name('posts.show');

This takes me to the show view of the post. The view is how I want the final product to be. (Please don’t mind the unfinished stuff, this is a hobby and I have not had enough time to finish the customization)

I want the state changes to go between the header and the post details. So ill be adding a _state.blade.php view to the current show.blade.php

<!-- _show.blade.php -->
@extends('admin.posts.posts')

@section('content')

    @include('admin.posts._state') // newly added state view
    @include('admin.posts._details')

@endsection

<div class="bg-white shadow overflow-hidden sm:rounded-lg p-4 text-gray-700 mb-4">
    {!! Form::open(['route' => ['admin.posts.state-transit', $post], 'method' => 'PUT']) !!}

        @if ($post->state->canTransitionTo(\App\States\Post\Published::class))
            <x-btn-no-class type="submit"
                            class="bg-blue-300 border-blue-400 text-blue-700 hover:bg-blue-400"
                            name="action"
                            value="published">
                Publish
            </x-btn-no-class>
        @endif

        @if ($post->state->canTransitionTo(\App\States\Post\Draft::class))
            <x-btn-no-class type="submit"
                            class="bg-gray-300 border-gray-400 text-gray-700 hover:bg-gray-400"
                            name="action"
                            value="draft">
                Draft
            </x-btn-no-class>
        @endif

    {!! Form::close() !!}
</div>

Here we are adding a form that will be submit a put request to /posts/{post}/state-transit endpoint. Yes, we have not yet created this. We are also adding two buttons for state change. One is publishing button which will be displayed if the post allows publishing. Remember back in PostState we set a rule that post will only be able to change to published state if and only if the posts current state is draft.

Like this, we have also added a draft button which will also be displayed if the post allows it to be transited to draft state. This can be extremely helpful if you have many states or when you want to add more states to post.

canTransitionTo() method is provided by the package, you don’t need to implement this on your own. The method checks the allowTransition methods given in the config of PostState class. So now lets add the put route in place.

Route::match(['PUT', 'PATCH'], 'posts/{post}/state-transit', [PostsController::class, 'stateTransit'])->name('posts.state-transit');

This will direct the request to a method names stateTransit in PostsController class. So lets add the class.

// PostsController.php
    public function stateTransit(Post $post, Request $request)
    {
        // logic
    }

Ok we have created the form, added the routes and the controller method. Remaining is Validating the request Checking authorization of user to perform the specific action Performing the action and redirecting to the page Lets validate the request first, ill do it within the controller to make it simple, you may extract the validation to a request class if you like.

$request->validate([
            'action' => new ValidStateRule(PostState::class)
        ]);

Here I am using the rules provided by spatie to validate the states. Once validated, now I can retrieve the state class using make method then call transitionTo on post to transit to the given method. But since we are restricting some transitions, its likely that a user might do something (like double clicking the transition button) that may throw a TransitionNotFound exception. So I will handle that as well.

       $state_class = PostState::make($request->action, $post);

        try {
            $post->state->transitionTo($state_class);
        } catch (TransitionNotFound $exception) {
            flash()->error("Transition not found");
            return redirect()->back();
        }
        return redirect()->back();

Note that the flash()->error(), that’s another one of spaties package for flashing a message. You may use it or not, but the point here is handling the exception gracefully. Since all the logic and work is done, remaining is a redirect to the previous page. With this all the parts should be working and you (if followed) should have implemented state transitioning to your model.

Couple of things you can do more is, Adding a permission to state transition. You can do this by creating a custom transition class and adding a canTransition metod. This will also throw a TransitionNotAllowed exception on failure, so make sure to handle that too.

If you are adding a permission, add the permission check using @can to blade file too. You would not be wanting the user see a button he/she can not use.

That’s it for today. If you find this guide helpful, please share!

Peace!