Develop a Blog - Part 3: BlogPost status

In the last article we used Test Driven Development (TDD) to create the constructors of our first three domain models: Category, Author and BlogPost. Today we will focus a bit more on the BlogPost. Based on the requirements in part 1 we came to the conclusion that we have three statuses for a BlogPost: Draft, Scheduled and Published. In this article we will work out the code around these statuses using the State Design Pattern.

This article is part of a series:

You can find the code of the previous articles and this one on Github:

Before we dive into the code again we should first do some designing. In the requirements we noticed that there are three statuses for a BlogPost: Draft, Scheduled and Published. The requirements state nothing about how we can transition between these statuses and that is something we should clarify before we begin.

State diagram

I'm a visual person and I work best when I draw out my thoughts. For our current problem we can use an UML state diagram. A state diagram shows all possible states, in this case the states of a BlogPost, and all possible transitions between these states. In our case we have three states:

  1. Draft
  2. Scheduled
  3. Published

A Draft BlogPost can be scheduled and published.

A Scheduled BlogPost can be scheduled for a different date and published, but cannot be put back to Draft.

A Published BlogPost cannot be put back to Draft, or Scheduled or Published.uiiNkijV2YxHAru9n6rMWfob9SBgluSLRCNuRBp3.pngThis is how the states would look like in an UML State diagram.

Any other behaviour we can tie to a BlogPost state? Yes, we need to attach a publish date to a BlogPost that is scheduled or published. A draft BlogPost doesn't have a publish date. We keep this requirement in mind during the implementation.

Naïve implementation of states

Let's start with an implementation of this State Diagram. A new BlogPost starts in the Draft state:

/** @test */
public function a_new_blogpost_is_in_draft()
{
    $blogPost = $this->createBlogPost();

    $this->assertEquals(BlogPost::STATUS_DRAFT, $blogPost->getStatus());
}

And the implementation:

const STATUS_DRAFT = 'draft';

public function __construct(
    Author $author,
    Category $category,
    string $title,
    string $introduction,
    string $content,
) {
    $this->validate($title, $introduction, $content);

    $this->author = $author;
    $this->category = $category;
    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;

    $this->status = self::STATUS_DRAFT;
}

public function getStatus() : string
{
    return $this->status;
}

Next, we can schedule a BlogPost that is in the Draft state, but we will need to provide a publish date.

/** @test */
public function a_draft_blog_post_can_be_scheduled()
{
    $tomorrow = new \DateTimeImmutable('tomorrow');

    $blogPost = $this->createBlogPost();
    $blogPost->schedule($tomorrow);

    $this->assertEquals(BlogPost::STATUS_SCHEDULED, $blogPost->getStatus());
    $this->assertEquals($tomorrow, $blogPost->getPublishDate());
}

We expect a new method schedule on the BlogPost with the publish date as a parameter.

public function getPublishDate() : \DateTimeImmutable
{
    return $this->publishDate;
}

public function schedule(\DateTimeImmutable $publishDate) : void
{
    $this->status = self::STATUS_SCHEDULED;
    $this->publishDate = $publishDate;
}

We can of course only schedule a blog post in the future. We must make sure that the publish date is valid and otherwise throw an Exception:

/** @test */
public function can_only_schedule_a_blogpost_in_the_future()
{
    $this->expectException(ScheduleError::class);

    $blogPost = $this->createBlogPost();
    $blogPost->schedule(
        publishDate: new \DateTimeImmutable('yesterday')
    );
}

Implementing this test is straightforward:

public function schedule(\DateTimeImmutable $publishDate) : void
{
    if (new \DateTimeImmutable('now') >= $publishDate) {
        throw new ScheduleError('Can only schedule a blog post in the future');
    }

    $this->status = self::STATUS_SCHEDULED;
    $this->publishDate = $publishDate;
}

We can also publish a draft BlogPost. The publish date will automatically be the current date and time. Otherwise we would have scheduled it instead.

/** @test */
public function a_draft_blog_post_can_be_published()
{
    $blogPost = $this->createBlogPost();
    $blogPost->publish();

    $this->assertEquals(BlogPost::STATUS_PUBLISHED, $blogPost->getStatus());
    $this->assertEqualsWithDelta(
        (new \DateTimeImmutable('now'))->getTimestamp(),
        $blogPost->getPublishDate()->getTimestamp(),
        1
    );
}

We want to to verify that the publish date is set on now, but while executing the test some time passed. To still make the comparison we use assertEqualsWithDelta. This method allows a deviation or delta on the two numbers it compares. We set the delta on one second. Which would be enoughto make this test work.

public function publish() : void
{
    $this->status = self::STATUS_PUBLISHED;
    $this->publishDate = new \DateTimeImmutable('now');
}

We have implemented all transitions from the draft state. We can continue with the scheduled state.

/** @test */
public function a_scheduled_blog_post_can_be_rescheduled()
{
    $nextWeek = new \DateTimeImmutable('+1 week');

    $blogPost = $this->createBlogPost();
    $blogPost->schedule(new \DateTimeImmutable('tomorrow'));
    $blogPost->schedule($nextWeek);

    $this->assertEquals(BlogPost::STATUS_SCHEDULED, $blogPost->getStatus());
    $this->assertEquals($blogPost->getPublishDate(), $nextWeek);
}

/** @test */
public function a_scheduled_blog_post_can_be_published()
{
    $blogPost = $this->createBlogPost();
    $blogPost->schedule(new \DateTimeImmutable('tomorrow'));
    $blogPost->publish();

    $this->assertEquals(BlogPost::STATUS_PUBLISHED, $blogPost->getStatus());
    $this->assertEqualsWithDelta(
        (new \DateTimeImmutable('now'))->getTimestamp(),
        $blogPost->getPublishDate()->getTimestamp(),
        1
    );
}

We have two more tests: publishing a scheduled blog post should change the status to published and the publish date to now. Scheduling a scheduled BlogPost should keep the status the same, but change the publish date to the new scheduled date. We can add the tests, but this should already work with our existing code.

The Published status is an interesting one. As you can see in the State diagram, there are no transitions from the Published status. Currently, our code does support transition from the Published state. We need to address that.

/** @test */
public function a_published_blog_post_cannot_be_scheduled()
{
    $this->expectException(ScheduleError::class);

    $blogPost = $this->createBlogPost();
    $blogPost->publish();
    $blogPost->schedule(new \DateTimeImmutable('tomorrow'));

    $this->assertEquals($blogPost->getStatus(), BlogPost::STATUS_PUBLISHED);
    $this->assertEqualsWithDelta(
        (new \DateTimeImmutable('now'))->getTimestamp(),
        $blogPost->getPublishDate(),
        1
    );
}

/** @test */
public function a_published_blog_cannot_be_published_again()
{
    $this->expectException(PublishError::class);

    $blogPost = $this->createBlogPost();
    $blogPost->publish();
    $blogPost->publish();
}

This is easily dealt with by adding a few guard clauses on the schedule and publish classes.

public function schedule(\DateTimeImmutable $publishDate) : void
{
    if($this->status === self::STATUS_PUBLISHED) {
        throw new ScheduleError('Cannot schedule a published blog post');
    }

    if (new \DateTimeImmutable('now') >= $publishDate) {
        throw new ScheduleError('Can only schedule a blog post in the future');
    }

    $this->status = self::STATUS_SCHEDULED;
    $this->publishDate = $publishDate;
}

public function publish() : void
{
    if($this->status === self::STATUS_PUBLISHED) {
        throw new PublishError('Cannot schedule a published blog post');
    }

    $this->status = self::STATUS_PUBLISHED;
    $this->publishDate = new \DateTimeImmutable('now');
}

One last requirement and we are done for today. A draft BlogPost doesn't have a publish date. The getPublishDate should return null when we call it.

/** @test */
public function a_draft_blog_post_doesnt_have_a_publish_date()
{
    $blogPost = $this->createBlogPost();
    $this->assertNull($blogPost->getPublishDate());
}

And again easy to implement by adding the following line to the BlogPost constructor:

$this->publishDate = null;

We satisfy all requirements around BlogPost status. And we could finish the article right now, but I'm not satisfied with our solution yet. The code is simple and easy to read, but as soon as there are more requirements around BlogPost status the code can become very hairy quickly. We are green, so we have the chance to refactor and make sure that our code is prepared for change.

Refactoring towards the State design pattern

The state pattern is a behaviour pattern that allows an object to change its behaviour based on its state. The state pattern achieves this by delegating the state specific code to a state object. In case of our Blog we would define a separate class for Draft, Scheduled and Published. The BlogPost would delegate state related functionality to these classes. Applying the state pattern would reduce the amount of if-else statements that we need.

We can start our refactoring with the getStatus() method. Currently we have a $status field in the Blogpost class that holds a string. We will change that to the Status interface. The getStatus() method will delegate its work to a getName() method on the Status interface.

interface Status
{
    public function getName() : string;
}

class Draft implements Status
{
    const NAME = 'draft';

    public function getName(): string{
        return self::NAME;
    }
}

class Scheduled implements Status
{

    const NAME = 'scheduled';

    public function getName(): string{
        return self::NAME;
    }
}

class Published implements Status
{

    const NAME = 'published';

    public function getName(): string{
        return self::NAME;
    }
}

class BlogPost
{
    private Status $status;

    public function __construct(
        Author $author,
        Category $category,
        string $title,
        string $introduction,
        string $content,
    ) {
        $this->validate($title, $introduction, $content);

        $this->author = $author;
        $this->category = $category;
        $this->title = $title;
        $this->introduction = $introduction;
        $this->content = $content;

        $this->status = new Draft();
        $this->publishDate = null;
    }

    public function getStatus() : string{
        return $this->status->getName();
    }
}

Next we can move the publish method to the Status interface. The Draft and Scheduled states will return a new Published object. The Published state will throw a PublishError exception.

interface Status
{
    public function getName() : string;
    public function publish() : Status;
}

class Draft implements Status
{
    const NAME = 'draft';

    public function getName(): string{
        return self::NAME;
    }

    public function publish(): Status{
        return new Published();
    }
}

class Scheduled implements Status
{

    const NAME = 'scheduled';

    public function getName(): string{
        return self::NAME;
    }

    public function publish(): Status{
        return new Published();
    }
}

class Published implements Status
{

    const NAME = 'published';

    public function getName(): string{
        return self::NAME;
    }

    public function publish(): Status{
        throw new PublishError('Cannot publish a blog post');
    }
}

In BlogPost we can get rid of the if-else block in the publish method and just replace it with a delegation to the publish method on the Status interface.

public function publish() : void
{
    $this->status = $this->status->publish();
    $this->publishDate = new DateTimeImmutable('now');
}

Then we can continue with the schedule and getPublishDate methods. The schedule method is again responsible for the state transition or exception if no transition exists. The getPublishDate will return the publish date based on the status. This also means that we can remove the publishDate attribute from the BlogPost. The check for an incorrect publishDate can be moved to the constructor of the Scheduled status class.

The new Status interface:

interface Status
{
    public function getName() : string;
    public function getPublishDate() : ?DateTimeImmutable;
    public function publish() : Status;
    public function schedule(DateTimeImmutable $publishDate) : Status;
}

The Draft status

class Draft implements Status
{
    const NAME = 'draft';

    public function getName(): string
    {
        return self::NAME;
    }

    public function getPublishDate(): ?DateTimeImmutable
    {
        return null;
    }

    public function publish(): Status
    {
        return new Published();
    }

    public function schedule(DateTimeImmutable $publishDate): Status
    {
        return new Scheduled($publishDate);
    }
}

The Scheduled status

class Scheduled implements Status
{
    const NAME = 'scheduled';

    private DateTimeImmutable $publishDate;

    public function __construct(DateTimeImmutable $publishDate)
    {
        if (new DateTimeImmutable('now') >= $publishDate) {
            throw new ScheduleError('Can only schedule a blog post in the future');
        }

        $this->publishDate = $publishDate;
    }


    public function getName(): string{
        return self::NAME;
    }

    public function getPublishDate(): ?DateTimeImmutable{
        return $this->publishDate;
    }

    public function publish(): Status{
        return new Published();
    }

    public function schedule(DateTimeImmutable $publishDate): Status{
        return new Scheduled($publishDate);
    }
}

The published status

class Published implements Status
{
    const NAME = 'published';

    private DateTimeImmutable $publishDate;

    public function __construct()
    {
        $this->publishDate = new DateTimeImmutable('now');
    }


    public function getName(): string{
        return self::NAME;
    }

    public function getPublishDate(): ?DateTimeImmutable{
        return $this->publishDate;
    }

    public function publish(): Status{
        throw new PublishError('Cannot publish a blog post');
    }

    public function schedule(DateTimeImmutable $publishDate): Status{
        throw new ScheduleError('Cannot schedule a published blog post');
    }
}

And the getPublishDate and schedule methods in the BlogPost class:

public function getPublishDate() : ?DateTimeImmutable
{
    return $this->status->getPublishDate();
}

public function schedule(DateTimeImmutable $publishDate) : void
{
    $this->status = $this->status->schedule($publishDate);
}

We were able to move from a naïve state machine implementation to one using the state pattern. We were supported by the tests that we created and helped us making sure that the functionality of the code stayed the same while refactoring. This concludes the current article. In the next article we will continue with our work on the BlogPost class and add MarkDown and HTML parsing.

Full source code

The source code for the Blog and the code for this article are available on Github:

Author

Mark Kazemier's avatar
Mark Kazemier

Hi, my name is Mark. I'm the founder of webdevils.nl and love developing websites and other web applications. Through Webdevils.nl I want to spread my enthousiasm about the web and PHP. In my professional live I'm a security expert specialised in security monitoring.

View all posts