Refactoring to actions
In our recent projects at Spatie, we've started using a concept called "actions". It keeps our controllers and models skinny. It's a straightforward practice. In this blog post, I'd like to explain it to you.
From logic in controllers and models...
Consider you have a Laravel powered blog where you want to publish posts. When a post is published, the app should tweet out the title and link to it.
The controller that does that might look like this:
class PostsController
{
public function create()
{
// ...
}
public function store()
{
// ...
}
public function edit()
{
// ...
}
public function update()
{
// ...
}
public function delete()
{
// ...
}
public function publish(Post $post, TwitterApi $twitterApi)
{
$post->markAsPublished();
$twitterApi->tweet($post->title . PHP_EOL . $post->url);
flash()->success('Your post has been published!');
return back();
}
}
If you're wondering why this controller doesn't extend the default controller, head over to this blog post on simplifying controllers.
To me, it's a bit dirty that a non-cruddy action lives in a crud controller. Let's follow Adam's advice and put the publish
method in its own controller.
class PublishPostController
{
public function __invoke(Post $post, TwitterApi $twitter)
{
$post->markAsPublished();
$twitter->tweet($post->title . PHP_EOL . $post->url);
flash()->success('Your post has been published!');
return back();
}
}
That's already a bit nicer, but we can take it even further. Imagine you want to create an artisan command to publish blog posts. Right now, this isn't possible because the logic to do this is inside the controller.
To make logic callable from a command (or anywhere else in an app) that logic shouldn't be in a controller. Ideally, the only code that is placed in a controller is code that handles the HTTP layer.
You might be tempted to move all this code to a publish
method on the Post
model. For smallish projects that is fine. But imagine there are more kinds of actions on a post, like archiving or duplicating. All these actions will make your model big.
... to logic in actions!
Instead of leaving this logic in the controller or putting it in a model, let's move it to a dedicated class. At Spatie, we call these classes "actions".
An action is a very simple class. It only has one public method: execute
. You could name that method whatever you want.
namespace App\Actions;
use App\Services\TwitterApi;
class PublishPostAction
{
/** @var \App\Services\TwitterApi */
private $twitter;
public function __construct(TwitterApi $twitter)
{
$this->twitter = $twitter;
}
public function execute(Post $post)
{
$post->markAsPublished();
$this->tweet($post->title . PHP_EOL . $post->url);
}
private function tweet(string $text)
{
$this->twitter->tweet($text);
}
}
Notice that markAsPublished
method being called on $post
? Because our app now has a dedicated place for published a post, that logic could move to this PublishPostAction
, making the Post
model a bit lighter.
// in PublishPostAction
public function execute(Post $post)
{
$this->markAsPublished($post);
$this->tweet($post->title . PHP_EOL . $post->url);
}
private function markAsPublished(Post $post)
{
$post->published_at = now();
$post->save();
}
private function tweet(string $text)
{
$this->twitter->tweet($text);
}
In a controller, you can call the action like this:
namespace App\Http\Controllers;
use App\Actions\PublishPostAction;
class PublishPostController
{
public function __invoke(Post $post, PublishPostAction $publishPostAction)
{
$publishPostAction->execute($post);
flash()->success('Hurray, your post has been published!');
return back();
}
}
We use method injection to resolve PublishPostAction
so Laravel's container will automatically inject a TwitterApi
instance into the PublishPostAction
itself.
An artisan command could now make use of the action too.
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Actions\PublishPostAction;
use App\Models\Post;
class PublishPostCommand extends Command
{
protected $signature = 'blog:publish-post {postId}';
protected $description = 'Publish a post';
public function handle(PublishPostAction $publishPostAction)
{
$post = Post::findOrFail($this->argument('postId'));
$publishPostAction->execute($post);
$this->comment('The post has been published!');
}
}
Another benefit we get from extracting to actions is that the code because it isn't tied to the HTTP layer anymore, is now more testable.
class PublishPostActionTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::createFromFormat('Y-m-d H:i:s', '2019-01-01 01:23:45'));
TwitterApi::fake();
}
/** @test */
public function it_can_publish_a_post()
{
$post = factory(Post::class)->state('unpublished')->create();
(new PublishPostAction())->execute($post);
$this->assertEquals('2019-01-01 01:23:45', $post->published_at->format('Y-m-d H:i:s'));
TweetterApi::assertTweetSent();
}
}
Queueable actions
Imagine you have an action that performs some work that takes some time. A simple solution for this would be to create a queued job and dispatch that job from within the action.
Let's use a queue in the PublishPostAction
to send out the tweet.
// in PublishPostAction
public function execute(Post $post)
{
$this->markAsPublished($post);
$this->tweet($post->title . PHP_EOL . $post->url);
}
private function markAsPublished(Post $post)
{
$post->published_at = now();
$post->save();
}
private function tweet(string $text)
{
dispatch(new SendTweetJob($text));
}
Now, if you want to send tweets from somewhere else in your application. Sure you could do it like this using a job:
namespace App\Http\Controllers
class SendTweetController
{
public function __invoke(SendTweetRequest $request)
{
dispatch(new TweetJob($request->text);
flash()->success('The tweet has been sent');
return back();
}
}
That'll work perfectly. But wouldn't it be nice if we could use actions for everything, including asynchronous work?
Enter our laravel-queueable-action package. This package allows you to queue actions easily. You can make an action queueable by applying the provided QueueableAction
to it. That trait adds an onQueue
method.
use Spatie\QueueableAction\QueueableAction;
namespace App\Actions;
class SendTweetAction
{
use QueueableAction;
/** @var \App\Services\TwitterApi */
private $twitter;
public function __construct(TwitterApi $twitter)
{
$this->twitter = $twitter;
}
public function execute(string $text)
{
$this->twitter->tweet($text);
}
}
Now we can call the action, and it will perform its work on a queue.
class SendTweetController
{
public function __invoke(SendTweetRequest $request, SendTweetAction $sendTweetAction)
{
$sendTweetAction->onQueue()->execute($request->text);
flash()->success('The tweet will be sent very shortly!');
return back();
}
}
You can also specify the queue where the work should be performed by passing its name to onQueue
.
$sendTweetAction->onQueue('tweets')->execute($request->text);
If you want to know more about queueable actions, be sure to check our this informative blog post by my colleague and package creator Brent.
In closing
Extracting logic to actions makes that action callable from multiple places in your app. It also makes the code easier to test. If actions become big, you can divide them into smaller actions.
At Spatie, we've named this concept "action" and are using the execute
method. You can call the concept and the method whatever you want. We didn't invent this practice. There are a lot of devs using it already. If you're coming from the DDD-world, you probably noticed that an action is just a command and its handler wrapped together.
Do you have any questions or remarks? Let me know in the comments below.
I'm a bit late on the hype, but how does a Action differ from a service where a service is also bothered with business logic? To me, they seem to be doing in general the same thing, so would you say that there is a positive addition to choosing an action over a service?