Develop a blog - Part 6: Updating existing BlogPosts

/
/
11 months ago
/

In the last few articles we created the functionality for a Blog Author to create blog posts and for a Blog Reader to read the blog posts. The next step is to allow Blog Authors to update existing blog posts. We want to allow multiple authors on the same blog post and of course almost nobody writes the perfect blog post in one go.

The business logic around updating a blog post will be different based on the status of a blog post. For example: we have multiple authors on a single blog post. If someone creates a blog post we link it to the author creating the blog post. When someone updates the blog post, we want that person to be added to the authors list. But only if they do a significant contribution to the blog post. For this article, we will assume that updating a draft blog post is a significant contribution, but when the blog post is already published or scheduled that it is probably just a (spelling) mistake that has been fixed. Which means: updating a draft blog post will add an author. Updating a published or scheduled blog post won't add an author.

Feel free to choose different business logic if it makes sense for you. It really depends on your own requirements how the business logic would look like.

Let's start implementing.

This article is part of a series. Read the other parts if you didn't read them yet:

The source code for the full series and the changes I made in this article are available on Github.

Updating requirements

In the initial blog requirements we only had the requirement that an author must be able to update a blog post. We stated no additional requirements. That is fine, but before we continue implementing the update functionality without any additional business logic, let's think for moment.

In the introduction I already stated a requirement around multiple authors. We could let the Author manually add an author to a blog post, but it is much nicer if it happens automatically. This will enforce the business logic and allows the authors to focus on writing blog posts.

The next thing to think about is: which fields of a blog post do we allow an Author to change? We have the following options:

  • Slug: is based on the title, so should probably change. But what should we do if the blog post was already published? Changing the slug would break backlinks to your blog post
  • Author: we already discussed, we add the author updating the blog post when the blog post is still in draft
  • Title
  • Introduction
  • Content
  • Parser: we don't allow this to change. When creating a blog post you choose if you use HTML or Markdown or something else. It doesn't make sense to change that for an existing blog post

As already stated in this list, we have to come up with some requirements around slugs and editing. For a draft or scheduled blog post it is fine to update the slug based on the title. When the blog post is published, it can lead to some problems. We have three options here:

  1. Just change the slug based on the title and ignore the problem with backlinks
  2. Keep the slug the same and accept that the title and slug are out of sync
  3. Keep a list of old slugs for a blog post and redirect the user when they request the blog post based on the old slug

Even though option 1 is the easiest, I don't think it is a very good solution. Backlinks are an important way to attract people to your blog. Missing blog posts aren't good for your Search Engine Optimization (SEO) either. That leaves us with option 2 and 3. Both are good options. I personally think option 3 is the nicest solution. It keeps the title and slug in sync and also keeps backlinks working.

Based on these discussions we come with the following requirements for updating blog posts:

  1. An author must be able to update the title, introduction and content of a blog post
  2. When updating the type of content (the parser) must stay the same; a HTML blog post stays a HTML blog post
  3. The slug must stay in sync with the title of a blog post
  4. When a blog post is published, it must redirect readers from the old to the new slug
  5. The author will become a co-author when updating a draft blog post

A nice list of requirements to start off with!

Updating title, introduction and content

Let's start with the first requirement: an author must be able to update the title, introduction and content of a blog post. We can allow that by adding an update method to BlogPost.

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

    $blogPost->update(
        title: 'New title',
        introduction: 'New introduction to the blog post',
        content: 'New content for the blog post!'
    );

    $this->assertEquals('New title', $blogPost->getTitle());
    $this->assertEquals('New introduction to the blog post', $blogPost->getIntroduction());
    $this->assertEquals('New content for the blog post!', $blogPost->getContent());
}

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

    try {
        $blogPost->update(
            title: '',
            introduction: '',
            content: ''
        );

        $this->fail('Should throw InvalidBlogPost exception');
    } catch (InvalidBlogPost $e) {
        $this->assertArrayHasKey('title', $e->getErrors());
        $this->assertArrayHasKey('introduction', $e->getErrors());
        $this->assertArrayHasKey('content', $e->getErrors());
    }
}

Of course we need to make sure that the BlogPost stays valid during updating. I added a simple test, just to verify if we get any validation. We already tested the validation when creating a blog post.

public function update(string $title, string $introduction, string $content) : void
{
    $this->validate($title, $introduction, $content);

    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

The next requirement we already adhere to, we don't allow the author to change the parser when updating.

Syncing slug

We allow the author to update the title and we must keep the slug in sync. We start with the happy case. When the blog post is draft or scheduled we want to just update the slug.

protected function createBlogPost(string $title = 'My first blog post',
    string $introduction = 'A short introduction to the BlogPost',
    string $content = 'The content of the full article',
    Parser $parser = null
): BlogPost {
    $generator = $this->createSlugGenerator();

    if ($parser === null) {
        $parser = $this->createStub(Parser::class);
        $parser->method('parse')
            ->will($this->returnArgument(0));
    }

    return new BlogPost(
        generator: $generator,
        parser: $parser,
        author: new Author('Mark'),
        category: $this->createCategory(),
        title: $title,
        introduction: $introduction,
        content: $content,
    );
}

protected function createSlugGenerator(): SlugGenerator
{
    $repository = $this->createStub(SlugRepository::class);
    $repository->method('exists')->willReturn(false);
    $generator = new SlugGenerator($repository);
    return $generator;
}

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

    $blogPost->update(
        title: 'New title',
        introduction: $blogPost->getIntroduction(),
        content: $blogPost->getContent()
    );

    $this->assertEquals(
        new Slug('new-title'),
        $blogPost->getSlug()
    );
}

/** @test */
public function slug_is_updated_when_updating_a_scheduled_blogpost()
{
    $nextWeek = new DateTimeImmutable('+1 week');
    $blogPost = $this->createBlogPost();
    $blogPost->schedule($nextWeek);

    $blogPost->update(
        title: 'New title',
        introduction: $blogPost->getIntroduction(),
        content: $blogPost->getContent()
    );

    $this->assertEquals(
        new Slug('new-title'),
        $blogPost->getSlug()
    );
    $this->assertEquals([], $blogPost->getOldSlugs());
}

I did a small refactoring of our test before I created the new tests. Before the createBlogPost method had a $generator and $slug parameter. I went through the code and found that the $slug parameter wasn't used. $generator was only used to overwrite the SlugGenerator stub with a mock in the a_blogpost_generates_a_slug test. I don't think the added value of the mock justifies the extra complexity of the test and I removed it. The new code creates an actual SlugGenerator and provides it with a Stub SlugRepository that will always responds that the slug is unique.

public function update(string $title, string $introduction, string $content) : void
{
    $this->validate($title, $introduction, $content);

    $this->slug = $this->generator->generate($title);
    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

We need a SlugGenerator to create the new slug. Luckily we already provided one in the constructor of BlogPost. We can reuse the generator in our update method by adding it to an attribute of the class.

Keeping old slugs

Next up is the special case. When we update a published blog post, we want to remember the old slugs to allow a redirect to the new slug. We can implement that by adding a list of old slugs to the blog post and update the list when we update.

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

    $blogPost->update(
        title: 'New title',
        introduction: $blogPost->getIntroduction(),
        content: $blogPost->getContent()
    );

    $this->assertEquals(
        new Slug('new-title'),
        $blogPost->getSlug()
    );
    $this->assertEquals(
        [new Slug('my-first-blog-post')],
        $blogPost->getOldSlugs()
    );
}

The tests show the behaviour of the old slugs. When you create a new BlogPost it has no old slugs. When we update, we have an old slug.

Bringing the test to green will be a bit more tricky. We have changing behaviour based on the status of a blog post. To prevent too many if-else statements we can delegate the work for tracking old slugs to the status class. We update the Status interface with two methods: addOldSlug and getOldSlugs.

interface Status
{
    public function getName() : string;
    public function getPublishDate() : ?DateTimeImmutable;
    public function publish() : Status;
    public function schedule(DateTimeImmutable $publishDate) : Status;
    public function addOldSlug(Slug $slug) : void;
    public function getOldSlugs() : array;
}

For Draft and Scheduled these methods do nothing:

public function addOldSlug(Slug $slug): void
{
}

public function getOldSlugs(): array
{
    return [];
}

Published has some behaviour:

private array $oldSlugs = [];

public function addOldSlug(Slug $slug): void
{
    $this->oldSlugs[] = $slug;
}

public function getOldSlugs(): array
{
    return $this->oldSlugs;
}

The code in BlogPost will just delegate to the status:

public function getOldSlugs() : array
{
    return $this->status->getOldSlugs();
}

public function update(string $title, string $introduction, string $content) : void
{
    $this->validate($title, $introduction, $content);

    $this->status->addOldSlug($this->slug);
    $this->slug = $this->generator->generate($title);

    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

Resolving slug edge cases

There is one problem with our code however. What if we update the blog post, but don't change the title? In that case the slug shouldn't change, but it will. The update method will always ask the generator to generate a slug. The generator will check the repository and notice that the slug already exists and will add a sequence number at the end of the slug. Also, when the BlogPost is published, the slug will be added to the list of old slugs.

Below a test that would handle both edge cases:

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

    $oldSlug = $blogPost->getSlug();

    $blogPost->update(
        title: $blogPost->getTitle(),
        introduction: 'A new introduction to an old blog post',
        content: 'New content to an old blog post'
    );

    $this->assertEquals(
        $oldSlug,
        $blogPost->getSlug()
    );
    $this->assertEquals([], $blogPost->getOldSlugs());
}

We can resolve this issue by adding a conditional to the update method:

public function update(string $title, string $introduction, string $content) : void
{
    $this->validate($title, $introduction, $content);

    if ($title !== $this->getTitle()) {
        $this->status->addOldSlug($this->slug);
        $this->slug = $this->generator->generate($title);
    }

    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

Multiple authors

And we can start working on the last requirement. When updating a draft blog post the author should be added to the list of authors. We can create a test for the happy case:

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

    $blogPost->update(
        author: new Author('John Doe'),
        title: 'New title',
        introduction: $blogPost->getIntroduction(),
        content: $blogPost->getContent()
    );

    $this->assertEquals(
        [
            new Author('Mark'),
            new Author('John Doe')
        ],
        $blogPost->getAuthors()
    );
}

Making this test green takes a few steps. First we need to rename the getAuthor method to getAuthors and make sure it returns an array. Then, some previous tests need updating to use the getAuthors method instead. Implementing the update method to add an extra author is easy after these changes:

public function update(Author $author, string $title, string $introduction, string $content) : void
{
    $this->validate($title, $introduction, $content);

    $this->authors[] = $author;

    if ($title !== $this->getTitle()) {
        $this->status->addOldSlug($this->slug);
        $this->slug = $this->generator->generate($title);
    }

    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

Now we have to resolve an edge case again. If the same author updates the blog post twice, we only want them in the list of authors once. Let's add a test to guarantee that:

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

    $blogPost->update(
        author: new Author('Mark'),
        title: 'New title',
        introduction: $blogPost->getIntroduction(),
        content: $blogPost->getContent()
    );

    $this->assertEquals([new Author('Mark')], $blogPost->getAuthors());
}

We just add a conditional to the update method:

public function update(Author $author, string $title, string $introduction, string $content) : void
{
    $this->validate($title, $introduction, $content);

    if (!in_array($author, $this->authors)) {
        $this->authors[] = $author;
    }

    if ($title !== $this->getTitle()) {
        $this->status->addOldSlug($this->slug);
        $this->slug = $this->generator->generate($title);
    }

    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

Happy flow implemented, let's implement the "unhappy" flows. We should only add the author to the list of authors when we have a draft blog post. After publishing or scheduling we shouldn't add the author. We add two additional tests to make sure the author isn't added when the blogpost is scheduled or published:

/** @test */
public function no_authors_added_when_updating_a_scheduled_blogpost()
{
    $nextWeek = new DateTimeImmutable('+1 week');
    $blogPost = $this->createBlogPost();
    $blogPost->schedule($nextWeek);

    $blogPost->update(
        author: new Author('Antony'),
        title: 'New title',
        introduction: $blogPost->getIntroduction(),
        content: $blogPost->getContent()
    );

    $this->assertEquals(
        [new Author('Mark')],
        $blogPost->getAuthors()
    );
}

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

    $blogPost->update(
        author: new Author('Antony'),
        title: 'New title',
        introduction: $blogPost->getIntroduction(),
        content: $blogPost->getContent()
    );

    $this->assertEquals(
        [new Author('Mark')],
        $blogPost->getAuthors()
    );
}

Same as with the slug before. The behaviour is dependent on the status of a blogpost. We can add a conditional in the update method to check for the status and only add an author when the blog post is a draft, but it is easier to just delegate the behaviour to the status interface:

interface Status
{
    public function getName() : string;
    public function getPublishDate() : ?DateTimeImmutable;
    public function publish() : Status;
    public function schedule(DateTimeImmutable $publishDate) : Status;
    public function addOldSlug(Slug $slug) : void;
    public function getOldSlugs() : array;
    public function addAuthor(array $authors, Author $author) : array;
}

Scheduled and Published just return the list of authors unchanged:

public function addAuthor(array $authors, Author $author): array
{
    return $authors;
}

The code to add an author to the list moves to Draft:

public function addAuthor(array $authors, Author $author): array
{
    if (!in_array($author, $authors)) {
        $authors[] = $author;
    }

    return $authors;
}

And the update method of BlogPost delegates to it's status:

public function update(Author $author, string $title, string $introduction, string $content) : void
{
    $this->validate($title, $introduction, $content);

    $this->authors = $this->status->addAuthor($this->getAuthors(), $author);

    if ($title !== $this->getTitle()) {
        $this->status->addOldSlug($this->slug);
        $this->slug = $this->generator->generate($title);
    }

    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

And that's it! We implemented all requirements and allow authors to update blog posts.

Source code and next article

We implemented most of our business logic. The next step is to start talking about persistence. We want to store, update and retrieve blog posts from our persistence layer. The next article will go in depth on this topic.

The source code of the Blog project and the changes made in 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