This post is quite old now.
If it's a technical article, this means that some of the information in this post might be out of date.
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.
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.
// .gitignorevendor
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 vendortests/__fixtures__/users/*.yamltests/__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.
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
isIlluminate\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\LikeController@store')->name('like');Route::post('/dislike/{id}', '\Damcclean\Likes\Http\Controllers\LikeController@destroy')->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">{{ likes | count }} likes</span> {{ if logged_in }} {{ user }} {{ if (likes | to_json | contains:id) }} <form class="inline-block mr-4" action="/!/likes/dislike/{{ page:id }}" method="post"> {{ csrf_field }} <button class="font-semibold text-red-600">Dislike</button> </form> {{ else }} <form class="inline-block mr-4" action="/!/likes/like/{{ page:id }}" method="post"> {{ csrf_field }} <button class="font-semibold text-green-600">Like</button> </form> {{ /if }} {{ /user }} {{ /if }}</div>
(yes I know the above code snippet is broken, here's a Github Gist of what it's meant to look like)
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:
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!