One of the biggest typical problems in Laravel is too much logic in Controllers. We need to separate some logic somewhere. One option is a Command design pattern, implemented in Laravel by Queued Jobs and Action classes.
First, a practical example, and then we'll return to the design pattern theory.
Here's a long Controller method that takes care of three things:
- Updating the model data
- Preparing extra info for the model (OG image)
- Using external service to process the model (post a tweet)
app/Http/Controllers/PublishPostController.php:
use App\Models\Post;use App\Services\TwitterService;use Spatie\Browsershot\Browsershot; class PublishPostController{ public function __invoke(Post $post, TwitterService $twitter) { // ======================= // Step 1. Publish a post // ======================= $post->published = true; if (! $post->publish_date) { $post->publish_date = now(); } $post->save(); // ============================================ // Step 2. Create OG Image for Social Networks // ============================================ if (!$post->isTweet()) { $base64Image = Browsershot::url($post->ogImageBaseUrl()) ->devicePixelRatio(2) ->windowSize(1200, 630) ->base64Screenshot(); $post ->addMediaFromBase64($base64Image) ->usingFileName("{$post->id}.png") ->toMediaCollection('ogImage'); } // ======================= // Step 3. Tweet the Post // ======================= if ($post->send_automated_tweet && !$post->isTweet() && !post->tweet_sent) { $tweetText = $post->toTweet(); $tweetResponse = $twitter->tweet($tweetText); if (! isset($tweetResponse['data']->id)) { return; } $tweetUrl = "https://twitter.com/freekmurze/status/{$tweetResponse['data']->id}"; $post->onAfterTweet($tweetUrl); $post->update(['tweet_sent' => true]); } return view('front.posts.published'); }}
Notice: the example is loosely based on the open-source code of spatie/freek.dev, with many manual changes I made to demonstrate the design patterns.
Doesn't it look too big for a Controller?
It is big not only in terms of code lines but also in terms of the amount of information you need to understand what is happening.
Wouldn't Controller look better with something like this?
use App\Actions\PublishPostAction;use App\Models\Post;use App\Jobs\CreateOgImageJob;use App\Jobs\TweetPostJob; class PublishPostController{ public function __invoke(Post $post, PublishPostAction $publishPostAction) { $publishPostAction->execute($post); dispatch(new CreateOgImageJob($post)); dispatch(new TweetPostJob($post)); return view('front.posts.published'); }}
So we've separated the logic into Actions and Jobs, and the Controller is now perfectly readable, hiding the implementation of those separate operations.
The benefit is a separation of concerns:
- The controller doesn't know anything that is happening inside the Job/Action, so it's easier for developers to read the Controller
- With parameters, a Job class could be called (dispatched) from anywhere: other API/Web Controllers, other Jobs, Artisan Commands, Unit Tests, etc.
Now, let's dive into the differences between Jobs and Actions.
Job Classes: Strict and Queueable
So, we see two Job classes called from the Controller. Now, what's inside them?
app/Jobs/CreateOgImageJob.php:
namespace App\Jobs; use App\Models\Post;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels;use Spatie\Browsershot\Browsershot; class CreateOgImageJob implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $tries = 2; public function __construct( public Post $post ) {} public function handle(): void { if ($this->post->isTweet()) { return; } $base64Image = Browsershot::url($this->post->ogImageBaseUrl()) ->devicePixelRatio(2) ->windowSize(1200, 630) ->base64Screenshot(); $this ->post ->addMediaFromBase64($base64Image) ->usingFileName("{$this->post->id}.png") ->toMediaCollection('ogImage'); }}
What do we see here?
- A Job class has one method
handle()
which returnsvoid
and "takes care of the job" (pun intended) inside - It accepts the Post as a parameter via PHP 8 Constructor Property Promotion
- It implements
ShouldQueue
and uses many traits so that it can be potentially put into a queue
The second job TweetPostJob
is more interesting. It uses an external TwitterService
class to tweet about the post.
Notice: Based on the same example, we will discuss Service classes in the next course lesson.
app/Jobs/TweetPostJob.php:
namespace App\Jobs; use App\Models\Post;use App\Services\TwitterService;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels; class TweetPostJob implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( public Post $post ) {} public function handle(TwitterService $twitter): void { if (! $this->post->send_automated_tweet) { return; } if ($this->post->tweet_sent) { return; } if ($this->post->isTweet()) { return; } $tweetText = $this->post->toTweet(); $tweetResponse = $twitter->tweet($tweetText); if (! isset($tweetResponse['data']->id)) { return; } $tweetUrl = "https://twitter.com/freekmurze/status/{$tweetResponse['data']->id}"; $this->post->onAfterTweet($tweetUrl); $this->post->update(['tweet_sent' => true]); }}
Again, the same pattern:
-
handle()
method with void return - Post as a constructor parameter
- Queue-related traits
So, you must obey the rules of how things should be called inside the Job class.
Even the core structure of a new Job class, when you run php artisan make:job TweetPostJob
looks like this: __construct()
and handle()
.
namespace App\Jobs; use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels; class TweetPostJob implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * Create a new job instance. */ public function __construct() { // } /** * Execute the job. */ public function handle(): void { // }}
Usage of Queues
Speaking of queues, a helpful example would be to delay the second job of tweeting by 20 seconds. By that time, the OG image is likely finished generating:
Controller:
dispatch(new CreateOgImageJob($post));dispatch(new TweetPostJob($post))->delay(now()->addSeconds(20));
Queues are outside of the topic of design patterns; I have a separate course on queues.
Actions: Structure Freedom and No Queues
Now, let's get back to the Controller code and the PublishPostAction
class:
use App\Actions\PublishPostAction; use App\Models\Post;use App\Jobs\CreateOgImageJob;use App\Jobs\TweetPostJob; class PublishPostController{ public function __invoke(Post $post, PublishPostAction $publishPostAction) { $publishPostAction->execute($post); dispatch(new CreateOgImageJob($post)); dispatch(new TweetPostJob($post)); return view('front.posts.published'); }}
The first difference is how it is initiated: as a parameter in the Controller __invoke()
method.
This is a part of so-called "Laravel magic", also known as a Container, and we will discuss such type-hinting in future lessons.
But generally, the jobs are dispatched as new instances of those classes, with the handle()
method executed automatically. With Actions, Laravel will auto-create the object for you, but you need to manually call the method you want, like execute()
in this case.
And see, it's execute()
, not handle()
!
app/Actions/PublishPostAction.php:
namespace App\Actions; use App\Models\Post; class PublishPostAction{ public function execute(Post $post) { $post->published = true; if (! $post->publish_date) { $post->publish_date = now(); } $post->save(); }}
Did you notice that the Action class is simpler than the Job class?
This is one of the "philosophical" differences, even in the wording:
- A job is considered to be a longer process (potentially queued) with complicated logic
- An action is considered a quick action to execute some pretty well-defined operation
Now, if we get away from philosophy and look at the structure, a few key differences:
- Action classes are NOT a feature of Laravel. There's no
make:action
Artisan command. Action is just any PHP class you create manually. It doesn't implement any interface or use any Laravel traits. - Related to the above: you have complete freedom to name Action methods however you want. For Jobs, the rules are more strict.
And, obviously, you cannot put the Action into the queue: it's just for the execution right away, just offloading the implementation from the Controller to a separate Action class.
However, the benefit of reusability still remains: Action can be called from other Controllers, Artisan commands, Unit tests, etc.
Jobs Dispatched from Action Class?
In real-life projects, various classes and design patterns are used together where it makes sense.
So, in this case, wouldn't it be cool to just call the Action from the Controller, and then the Action would call whatever Jobs it needs?
Controller:
use App\Actions\PublishPostAction;use App\Models\Post; class PublishPostController{ public function __invoke(Post $post, PublishPostAction $publishPostAction) { $publishPostAction->execute($post); return view('front.posts.published'); }}
app/Actions/PublishPostAction.php:
namespace App\Actions; use App\Jobs\CreateOgImageJob;use App\Jobs\TweetPostJob;use App\Models\Post; class PublishPostAction{ public function execute(Post $post) { $post->published = true; if (! $post->publish_date) { $post->publish_date = now(); } $post->save(); dispatch(new CreateOgImageJob($post)); dispatch(new TweetPostJob($post))->delay(now()->addSeconds(20)); // ... dispatch whatever other Jobs }}
From the separation of concern principle, indeed, we "click the button" to call the Controller/Action method of publishing the post, we don't know (or care) what should happen inside.
Potentially, another developer who knows more details about the publishing procedure from a business requirements point of view can schedule OG images, tweets, postings to other social networks, etc.
Strictness of Design Patterns vs Laravel
Now, after practical examples, let's discuss the theoretical part.
If you google the explanations for the Command design pattern we're using here, they look much more complicated.
Here's an example UML diagram from Wikipedia:
So, for your class structure to behave strictly according to some design pattern, you must strictly follow those rules.
In the case of Laravel, most of the patterns we discuss in the course are more "loose" implementations of the pattern.
This was the main problem while preparing for this course: strictly matching the patterns with Laravel classes. I decided to use more practical Laravel examples instead of discussing the (boring) UML theory.
But, even while looking at Laravel Action/Job classes here, we see a different level of strictness. This is exactly the purpose of design patterns: to be recognizable and avoid confusion for other developers reading your code.
So, a Job in Laravel is clear for everyone:
- How to generate it.
- How to structure it.
- How to execute it.
- How to pass parameters.
It's all in the docs.
Action classes in Laravel are a total "Wild West". That's why they're confusing to explain to Laravel newcomers, because each developer implements Actions however they want.
So, from that point of view, I wouldn't call Action classes a "design pattern", they are more like "naming convention". But again, it's more like a philosophical discussion: what should and shouldn't we call a pattern? I will leave it for you to decide for yourself.
Next: From Jobs/Actions to Services
Not sure if you noticed, but Job/Action class methods typically are not expected to return any result. Their purpose is executing the command.
If your methods involve getting/transforming the data, making calculations, or using third-party tools, Service classes are more appropriate. Let's discuss them in the next lesson.
No comments or questions yet...