How to utilise a powerful programming pattern in Laravel - the Action Pattern
The action pattern is a common programming pattern that allows you to abstract code into elements known as actions. It's powerful and provides many advantages.
I'm a regular user of the action pattern as I like it's flexibility, the ability to cut down on potential repetition and how nicely it integrates with Laravel. It's become a really powerful technique that I often call upon when building up my platforms.
Not only that, but a lot of Laravel's ecosystem also heavily rely on the action pattern, which allows for easy extensibility and testability of their core packages.
What is the action pattern?
In programming terms, an action is usually a class designed to perform a single operation. For example, recording a users login.
I prefer using invokable classes that utilise PHP's __invoke
magic method (see docs) so that we're able to call it in a function-like manner. However you can just create a single public method on your job class, e.g. ->execute()
.
Where do I put it?
It's often hard to carefully consider the structure of your project and where everything should go. But for most of my projects (a typical Laravel application), actions would be stored under app/Actions
.
Actions are often tied with contracts to allow for easy extensibility using Laravel's Service Container, but for now we'll stick with simple classes.
For readability, action classes are commonly appended with Action
, for example RecordUserLoginAction
. This is particularly useful when scanning over your code as it allows you to quickly see you are calling upon an action.
Give me an example...
It's important to note that an action is meant to perform one small chunk of functionality, one specific action or task. If you're using actions for more than that, then chances are you're not using them as efficiently as you could!
So, with that, let's go with a very simplistic example. We'll create an action to record our users login. So every time that a user completes their login, we want to record that attempt in a database, along with the IP address that performed it, and when it was done.
<?php
namespace App\Actions;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class RecordUserLoginAction
{
public function __invoke(User $user, ?string $ipAddress = null): void
{
DB::table('user_logins')->insert([
'user_id' => $user->getKey(),
'ip_address' => $ipAddress,
'created_at' => now(),
]);
}
}
Now, using the power of Laravel's automatic injection, we can easily call upon our action elsewhere in our code. For example, when we've completed authentication in our controller:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use App\Actions\RecordUserLoginAction;
class LoginController
{
public function store(Request $request, RecordUserLoginAction $recordUserLogin)
{
$validated = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
// check if we can authenticate our user
if (!auth()->attempt($validated, true)) {
throw ValidationException::withMessages(['email' => 'Unknown login']);
}
// record the users login
$recordUserLogin(auth()->user(), $request->ip());
// redirect to their intended route.
return redirect()->intended();
}
}
Notice that you call an invokable class as if it were a closure, and provide the arguments needed.
By moving our action outside of the controller, we've reduced our controllers complexity, abstracting out reusable logic and, better yet, we've improved our features testability!
Lets give it a test
I'd love to - testing actions couldn't be easier! Tests should be about the key functionality of code. You should have login tests to ensure that you can login to your app, but separately we can check the functionality of our RecordUserLoginAction
in isolation. I'll write this as a simple Pest test:
<?php
use App\Models\User;
use App\Actions\RecordUserLoginAction;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
uses(LazilyRefreshDatabase::class);
it('can record the users logins', function () {
// mock a user record
$user = User::factory()->create();
// record our login attempt
app()->call(RecordUserLoginAction::class, ['user' => $user, 'ipAddress' => '127.0.0.1']);
// and check our database has got a value
expect(DB::table('user_logins'))
->count()->toBe(1);
});
Great, now we know that our action logic is functioning as expected, and as long as it's given the correct arguments will continue to function correctly within our login controller.
It also means that we don't have to worry about re-testing this functionality within our login controllers tests.
And that's all there is to it! It's a worthwhile abstraction that introduces flexibility, extensibility, swappability, testability and a whole lot more!
What about a real life example?
Ok, so I've recently been working with a well known stock management platform where I often need to pull a single product from their API. This action needed to be performed in real time so I was able to get the result and continue processing.
By using the action pattern, I can call on it wherever I like, it can easily be tested and I can enforce a common response using contracts. This gives me the flexibility to refactor in the future, perhaps to a new API endpoint or a new system altogether.
My contract:
<?php
namespace App\Contracts\Actions;
use App\Data\ProductData;
interface FetchesProductByIdAction
{
public function __invoke(): ProductData;
}
My action:
<?php
namespace App\Actions;
use App\Data\ProductData;
use Illuminate\Support\Facades\Http;
use App\Contracts\Actions\FetchesProductByIdAction;
class FetchProductByIdAction implements FetchesProductByIdAction
{
public function __invoke(string|int $id): ProductData
{
$response = Http::withToken(config('services.stock.api_key'))
->get(config('services.stock.endpoint') . '/products/' . $id);
if (!$response->successful()) {
throw new \Exception('Unable to retrieve product with ID ' . $id);
}
return new ProductData(
id: $id,
name: $response->json('data.name'),
stock: $response->json('data.inventory_count'),
);
}
}
And with that I've got a piece of code that I can easily test, refactor, call from anywhere and most importantly, it has a predictible, common response.
Now what?
If you haven't already, give it a go... I know you'll love it! And if you loved reading this article, please consider subscribing for more!