Building a Likes addon in Statamic 3

Published on April 14th, 2020.

Recently, I had to build out a help site for a client. One of the features in site was for the user to be able to like help article or forum posts. I managed to build it out reasonably quickly, I think it took me a day to get everything working.

I was thinking that this sort of addon would be a good candidate for building in a blog post. So that's exactly what I've done. It goes through each step of process, from bootstrapping the addon in Statamic's command-line to creating a small little front-end component for likes.

If you want to see what the whole thing looks like in a project, I've pushed it up to Github for you to see.

Bootstrapping our addon

The first thing we need to do is bootstrap all the things that we need for our addon. We'll need to create our service provider, setup all the Composer stuff, etc. I'm going to walk you through all of that.

You could always bootstrap your addon by using a boilerplate but we're going to do it from scratch in this tutorial.

To get started, Statamic actually provides a nice please command to get most of the stuff we need up and running. Just do php please make:addon damcclean/likes in your Terminal. Remember to replace damcclean/likes with the Composer package name for your addon.

Once that command has done it's stuff, you should see a new directory popup in your site's root directory. An addons directory. It'll contain two other folders, damcclean and then likes because that's my Composer package name.

In the likes folder, you'll see two things, a composer.json file for your addon and a src/ServiceProvider.php file which is our addon's service provider.

The composer.json file tells Statamic the name of your addon, it's description, the namespace for the Service Provider etc.

And the ServiceProvider.php is the class that tells Statamic what things should be booted up and registered. Whether that be routes or commands or middlewares, that's where they all get registered. We'll come back to this file later on in the tutorial.

We now have a Statamic addon running from addons/damcclean/likes. 🎉 Well Done us!!

Setting up our tests

I don't know about you but when I'm writing addons or any sort of backend code, I like to write tests to check that my code works and it returns what I want it to return.

You could always do your testing manually but I like to automate it. Laravel, the framework that sits behind Statamic, uses a testing framework called PHPUnit.

PHPUnit is the testing framework we're going to pull in, in a minute. We're also gonna pull in a thing called Orchestra Testbench. Testbench sits on top of PHPUnit and provides helper functionality for building Laravel packages. (if you've not guessed it already, a Statamic addon is a Laravel package)

Anyway, to install PHPUnit and Testbench, run this command inside your addon's directory. likes in my case.

composer require --dev orchestra/testbench phpunit/phpunit

After that command has done it's thing, you'll see some more files popup. You'll see a composer.lock file which you can ignore, and you'll see a vendor directory. In case your new to Composer, the vendor directory is where all of the Composer dependencies are installed. In our case, those dependencies are Testbench, PHPUnit and all of the other packages they require to run.

If you're planning on setting up a Git repository for this project, you're gonna want to ignore that vendor directory. It's never a good idea to keep those sort of things in Git (except in Statamic 2, but that's away from the point).

To do that, just create a .gitignore file and add the folder name.

// .gitignore
vendor

Also, you're going to want to install Statamic inside your addon's composer file too, so your test suite can take advantage of Statamic things.

Before you can just install it, you'll need to lower your minimum stability level for dependencies, which is easy enough to do in your composer.json file.

{
	...
	"minimum-stability": "dev"
}

And then you can require statamic into your addon with composer require statamic/cms.

Now, we need to setup our PHPUnit configuration file. You should create a file called phpunit.xml in your addon's root directory. I've also included the contents of the config file.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Test Suite">
            <directory suffix="Test.php">./tests</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">./app</directory>
        </whitelist>
    </filter>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_KEY" value="base64:xRIcDp1ReW8Y8rd9V9D7hOVV4TI7ThCF3FKxRg01Rm8="/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
        <env name="MAIL_DRIVER" value="array"/>
    </php>
</phpunit>

Next, we need to create the directory where all of our tests are going to live. Just call it tests and put it alongside the src folder in your addon's root directory.

Now that we have tests folder, we'll need to create two files.

The first of which is our Test Case. The TestCase is a class that will be extended by each of your tests to tell it how to run Statamic, how to run your addon, etc. Just create a file called TestCase.php in your tests folder with the following contents.

<?php

namespace Damcclean\Likes\Tests;

use Statamic\Facades\Entry;
use Statamic\Facades\Collection;
use Statamic\Extend\Manifest;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
use Damcclean\Likes\ServiceProvider;
use Statamic\Providers\StatamicServiceProvider;
use Statamic\Statamic;
use Statamic\Facades\User;
use Illuminate\Foundation\Testing\WithFaker;

abstract class TestCase extends OrchestraTestCase
{
    use WithFaker;

    protected function getPackageProviders($app)
    {
        return [
            StatamicServiceProvider::class,
            ServiceProvider::class,
        ];
    }

    protected function getPackageAliases($app)
    {
        return [
            'Statamic' => Statamic::class,
        ];
    }

    protected function getEnvironmentSetUp($app)
    {
        parent::getEnvironmentSetUp($app);

        $app->make(Manifest::class)->manifest = [
            'damcclean/likes' => [
                'id' => 'damcclean/likes',
                'namespace' => 'Damcclean\\Likes\\',
            ],
        ];

        Statamic::pushActionRoutes(function() {
            return require_once realpath(__DIR__.'/../routes/actions.php');
        });
    }

    protected function resolveApplicationConfiguration($app)
    {
        parent::resolveApplicationConfiguration($app);

        $configs = [
            'assets', 'cp', 'forms', 'static_caching',
            'sites', 'stache', 'system', 'users'
        ];

        foreach ($configs as $config) {
            $app['config']->set("statamic.$config", require(__DIR__."/../vendor/statamic/cms/config/{$config}.php"));
        }

        $app['config']->set('statamic.users.repository', 'file');
        $app['config']->set('statamic.stache', require(__DIR__.'/__fixtures__/config/statamic/stache.php'));
    }

    protected function makeUser()
    {
        return User::make()
            ->id((new \Statamic\Stache\Stache())->generateId())
            ->email($this->faker->email)
            ->save();
    }

    protected function makeCollection(string $handle, string $name)
    {
        Collection::make($handle)
            ->title($name)
            ->pastDateBehavior('public')
            ->futureDateBehavior('private')
            ->save();

        return Collection::findByHandle($handle);
    }

    protected function makeEntry(string $collectionHandle)
    {
        $slug = $this->faker->slug;

        Entry::make()
            ->collection($collectionHandle)
            ->blueprint('default')
            ->locale('default')
            ->published(true)
            ->slug($slug)
            ->data([
                'likes' => [],
            ])
            ->set('updated_by', User::all()->first()->id())
            ->set('updated_at', now()->timestamp)
            ->save();

        return Entry::findBySlug($slug, $collectionHandle);
    }
}

Most of that should just be a simple copy paste job, but there's one thing you'll need to adapt to make it work for your addon. In the getEnvironmentSetup function, you'll need to change Damcclean\Likes to your own package name.

Also, we'll need to let Composer know about our testing namespace, to do that, just add this to your addon's composer.json file.

"autoload-dev": {
	"psr-4": {
		"DoubleThreeDigital\\SimpleCommerce\\Tests\\": "tests"
	},
	"classmap": [
		"tests/TestCase.php"
  	]
},

Just to be sure, you might also want to run composer dump-autoload to make sure it picks the new namespace up.

Later on in this tutorial, we'll be running tests with real collections and entries, so there's some stuff we'll want to setup to fake that. First, create some folders:

  • tests/__fixtures__/config/statamic

  • tests/__fixtures__/content

  • tests/__fixtures__/users

You'll probably also want to add some more things to your addon's Gitignore file.

// .gitignore

vendor
tests/__fixtures__/users/*.yaml
tests/__fixtures__/content/*.yaml

As well, you'll want to create a stache.php file inside your fixtures/config/statamic folder. This file will be used in testing to override the default Stache config, basically just telling Statamic to use the directories we just created to store our content and users.

<?php

use Statamic\Stache\Stores;

return [

    /*
    |--------------------------------------------------------------------------
    | File Watcher
    |--------------------------------------------------------------------------
    |
    | File changes will be noticed and data will be updated accordingly.
    | This can be disabled to reduce overhead, but you will need to
    | either update the cache manually or use the Control Panel.
    |
    */

    'watcher' => true,

    /*
    |--------------------------------------------------------------------------
    | Stores
    |--------------------------------------------------------------------------
    |
    | Here you may configure which stores are used inside the Stache.
    |
    */

    'stores' => [

        'taxonomies' => [
            'class' => Stores\TaxonomiesStore::class,
            'directory' => base_path('content/taxonomies'),
        ],

        'terms' => [
            'class' => Stores\TermsStore::class,
            'directory' => base_path('content/taxonomies'),
        ],

        'collections' => [
            'class' => Stores\CollectionsStore::class,
            'directory' => __DIR__.'/../../content/collections',
        ],

        'entries' => [
            'class' => Stores\EntriesStore::class,
            'directory' => __DIR__.'/../../content/collections',
        ],

        'navigation' => [
            'class' => Stores\NavigationStore::class,
            'directory' => base_path('content/navigation'),
        ],

        'globals' => [
            'class' => Stores\GlobalsStore::class,
            'directory' => base_path('content/globals'),
        ],

        'asset-containers' => [
            'class' => Stores\AssetContainersStore::class,
            'directory' => base_path('content/assets'),
        ],

        'users' => [
            'class' => Stores\UsersStore::class,
            'directory' => __DIR__.'/../../users',
        ],

    ],

    /*
    |--------------------------------------------------------------------------
    | Indexes
    |--------------------------------------------------------------------------
    |
    | Here you may define any additional indexes that will be inherited
    | by each store in the Stache. You may also define indexes on a
    | per-store level by adding an "indexes" key to its config.
    |
    */

    'indexes' => [
        //
    ],

];

There we go! We've setup our test suite so it's all ready to go!

Writing some real code...

In order for users to like things on the site, we'll need to create a few things. We'll need to create a field where user IDs will be stored, we'll need to create a controller where the like/dislike submissions will go and we'll need to create some sort of front-end component that can send data to that controller.

OK, so let's take one thing at a time, starting with the field I was talking about. The easiest way to do any sort of content work is through the Control Panel. So just login to the CP, go to the blueprint where you want to use likes, and add a field.

You'll want to add a Users field because it allows us to store a list of Statamic user IDs, which we'll need to tell who has liked the entry.

Make sure to change the Max Items setting on the field so that it can store more than 1 user's ID.

statamic-likes-addon-users-field

Now that we've got the field in our blueprint, we need to create some controllers to do the legwork of actually liking and unliking entries.

In your addon's folder, addon/yourname/packagename, create a folder structure like this, src/Http/Controllers. In the Controllers folder you'll want to create a controller, you can call it whatever you want but I'll call mine LikeController.

My LikeController is going to have two methods, a store method which will be hit whenever a user likes an entry and a destroy method which will be hit whenever a user unlikes an entry.

<?php

namespace Damcclean\Likes\Http\Controllers;

class LikeController
{
    public function store()
    {
        //
    }

    public function destroy()
    {
        //
    }
}

Now we've got the methods, let's make them do something! I'm going to start with the store method.

So when someone makes a request to the controller, we want to know two things: the currently logged in user and the ID of the entry the user liked.

To do this, we're going to add the Request $request and the $id as method parameters.

Oh and by the way, Request is Illuminate\Http\Request

Next we're going to want to get an instance of the entry from the ID because the ID is just a randomly generated string. We can do that by calling the Entry facade, provided by Statamic, and it's find function.

public function store(Request $request, $id)
{
	$entry = Entry::find($id);
}

Next, we'll want to grab all of the likes the entry currently has and we'll want to add the logged in user's ID to that list.

In the code you can see below, we create the $likes variable, we check if there are any likes on entry at the moment, if yes, we use them, if not we just set an empty array.

We also go ahead and merge in the ID of the currently logged in user to that array.

public function store(Request $request, $id)
{
	$entry = Entry::find($id);

	$likes = $entry->value('likes') ?? [];
	$likes = array_merge($likes, [$request->user()->id()]);
}

Now all that's left for us to do is save the updated likes array back to entry file, which is as easy as pie.

public function store(Request $request, $id)
    {
        $entry = Entry::find($id);

        $likes = $entry->value('likes') ?? [];
        $likes = array_merge($likes, [$request->user()->id()]);

        $entry->fromWorkingCopy()
            ->set('likes', $likes)
            ->save();

		return back();
    }

I've returned went ahead and returned the user back to the page they came from, but that's up to you.

So we're done with the store method, now let's jump onto the destroy method which removes the users' like from the entry.

Again, we're going to use the Request $request and the $id as method parameters. We're also going to need to find the entry as well.

public function destroy(Request $request, $id)
{
	$entry = Entry::find($id);
}

Next we just want to remove the currently logged user's like from the array of likes and then save the entry. The way I'm going to handle it is by using Laravel's collect method, going through each of likes, removing the one with the user's ID and then outputting a fresh array.

public function destroy(Request $request, $id)
{
	$entry = Entry::find($id);

	$likes = collect($entry->data()->toArray()['likes'])
		->reject($request->user()->id())
        ->all();

  	$entry
		->fromWorkingCopy()
		->set('likes', $likes)
		->save();

	return back();
}

That's really all we need for the controllers. Next we need to create routes so the controller method can actually be hit from the web.

We're going to create Action routes, which means the URLs will look like this /!/likes/like and /!/likes/dislike.

We're going to need a routes file, create a routes folder in your addon's root and then create an actions.php file. This file will hold our addon's routes.

Your entire routes file only needs to be three lines long.

<?php

Route::post('/like/{id}', '\Damcclean\Likes\Http\Controllers\[email protected]')->name('like');
Route::post('/dislike/{id}', '\Damcclean\Likes\Http\Controllers\[email protected]')->name('dislike');

Sadly, Statamic doesn't register these routes automatically, so we'll need to go and register them in our addon's ServiceProvider.

Registering things in your service provider is actually incredibly easy. I'll just explain how you do it for routes, but it's pretty much the same thing for registering fieldtypes, tags, widgets, modifiers, etc, you get the gist.

protected $routes = [
	'actions' => __DIR__.'/../routes/actions.php',
];

As long as you've setup everything the same way I have, that should just work.

And the front-end

The backend code is all done, but we still need to actually build out the front-end so users can actually like/dislike entries.

In this example I'm going to build out a simple like/dislike thing in Antlers but there's no reason you couldn't use a Vue component or something along those lines to do it. There's no wrong answers.

I made my likes 'component' into a nice Antlers partial that displays a count of how many people have liked the entry and buttons to like/dislike depending on if the user has already liked the entry or not.

<div class="flex flex-row items-center my-6">
    <span class="mr-4">noparse_1118a2a6cd8eafc2e480c6605080a220 likes</span>

  	noparse_ea13238b14d7b9a147c2ec4aefdf2187
	  noparse_49ba055a0cd0e6311926dcc309520b41
		  noparse_d749667fe363141b2d65fa2f6672de6b
			  <form class="inline-block mr-4" action="/!/likes/dislike/noparse_e2c395b9bcecfdc77ea003e4ae485c2f" method="post">
				  noparse_d72e6155cc12f26434440b5e96dc4f14
				  <button class="font-semibold text-red-600">Dislike</button>
			  </form>
		  noparse_3a0a06724dfc4260f640a76aa21acf93
			  <form class="inline-block mr-4" action="/!/likes/like/noparse_e2c395b9bcecfdc77ea003e4ae485c2f" method="post">
				  noparse_d72e6155cc12f26434440b5e96dc4f14
				  <button class="font-semibold text-green-600">Like</button>
			  </form>
		  noparse_6bcd448fd3428ca9411a416c06c66026
	  noparse_a7f8b947b209e2547741a94e3aa8e430
  	noparse_6bcd448fd3428ca9411a416c06c66026 
</div>

If you look at the code for the two forms. They are the endpoints that point to our controller actions that we made earlier.

Finally, let's write some tests

We setup PHPUnit and Orchestra Testbench earlier in this tutorial but we still need to write some actual tests so that we can know for sure that our controller code does exactly what we want it to.

We're going to write a few tests. One test to make sure that we can like an entry and one to make sure we can dislike one. We're going to need a file for our tests, I'm just going to call mine LikeControllerTest.php and place it in my addon's tests directory.

To get us started, I've made a quick scaffolding of the class.

<?php

namespace Damcclean\Likes\Tests;

class LikeControllerTest extends TestCase
{
    /** @test */
    public function can_store_like()
    {
        //
    }

    /** @test */
    public function can_destroy_like()
    {
        //
    }
}

I usually use /** @test */ above my test methods but if that feels weird to you just add test_ in front of the method names, like test_can_store_like.

Let's start writing our first test. Before writing the test, we need to think about what we need to setup, what we need to do to run the code and what we need to do to make sure the code did its job.

What we need to setup: a user, a collection and an entry

What we need to do to run the code: hit the action route

What we need to do to make sure the code did its job: make sure the user's ID is inside of the entry's file

Let's start with the setup! We're going to setup our user, our collection and our entry with some helper methods I built into the TestCase you copied in earlier, just to make the process nice and easy.

$user = $this->makeUser();
$collection = $this->makeCollection('articles', 'Articles');
$entry = $this->makeEntry('articles');

Next, we'll want to hit the route that goes to the controller. Laravel provides quite a lot of HTTP testing stuff out of the box, so there's nothing custom needing done here.

$this
	->actingAs($user)
	->post(route('statamic.like', ['id' => $entry->id()]))
	->assertRedirect();

Basically what we're doing there is logging in as the user we just created, making a POST request to our Like route with the ID of the entry and we're asserting that we get a redirect status code back.

Heads up: Be sure to change the assertRedirect assertion to something else if you didn't return a redirect in your controller.

The last thing we want to check is that the user's ID has been added to the likes array of the entry. To do this we're going to get an updated instance of the Entry and we're going to do a quick check against the likes array.

$updatedEntry = Entry::find($entry->id());
$this->assertStringContainsString($user->id(), json_encode($updatedEntry->value('likes')));

You should end up with this in the end:

/** @test */
public function can_store_like()
{
	$user = $this->makeUser();
	$collection = $this->makeCollection('articles', 'Articles');
	$entry = $this->makeEntry('articles');

	$this
		->actingAs($user)
		->post(route('statamic.like', ['id' => $entry->id()]))
		->assertRedirect();

	$updatedEntry = Entry::find($entry->id());
	$this->assertStringContainsString($user->id(), json_encode($updatedEntry->value('likes')));
}

Now onto our test to make sure a user can dislike an entry. Before writing the test, I'm going to think about what I need to setup, what code I need to run and how I can check the code has worked correctly.

What we need to setup: a user, a collection and an entry where the user's ID is in the likes array

What we need to do to run the code: hit the action route

What we need to do to make sure the code did its job: make sure the user's ID is not inside of the entry's file

This test is pretty much identical to the last one, except from the fact we need to include the users ID in the likes array during setup and we need to check the user's ID is not in the likes array at the end.

So after we've created the entry, we just need to do the same sort of thing we did in the controller to add the user's ID to likes array.

$entry
	->fromWorkingCopy()
	->set('likes', [$user->id()])
	->save();

And then at the end when we asserted that the likes array in the entry contains the User ID, we just want to do the opposite so we use assertStringNotContainsString.

$this->assertStringNotContainsString($user->id(), json_encode($updatedEntry->value('likes')));

So in full, the delete test should look a little like this:

/** @test */
public function can_destroy_like()
{
	$user = $this->makeUser();
	$collection = $this->makeCollection('articles', 'Articles');
	$entry = $this->makeEntry('articles');

	$entry
		->fromWorkingCopy()
		->set('likes', [$user->id()])
		->save();

	$this
		->actingAs($user)
		->post(route('statamic.dislike', ['id' => $entry->id()]))
		->assertRedirect();

	$updatedEntry = Entry::find($entry->id());
	$this->assertStringNotContainsString($user->id(), json_encode($updatedEntry->value('likes')));
}

And then if you go into your addon's root directory in your Terminal and if you run ./vendor/bin/phpunit, you should get some green text, like this:

likes-addon-green-tests

That's it! You've built a likes addon, with a fully working test suite. You should be proud of yourself!

Hopefully this tutorial was clear enough for you to understand, if you have any questions, ping me on the Statamic discord and I'll try to help 😃

Big thanks to Jason Varga for proof reading this for me!

My picture

Duncan McClean