A simple way to organize your business logic in Laravel applications.
composer require iak/action<?php
namespace App\Actions;
use Iak\Action\Action;
class SayHelloAction extends Action
{
public function handle()
{
return "Hello";
}
}<?php
namespace App\Http\Controllers;
use App\Actions\SayHelloAction;
class HomeController extends Controller
{
public function index(SayHelloAction $action)
{
$result = $action->handle();
// Or create it using the make() method
$result = SayHelloAction::make()->handle();
return response()->json($result);
}
}Actions provide helpful static methods:
// Create an instance
$action = SayHelloAction::make();
// Create a fake for testing
$action = SayHelloAction::fake();
// Create a testable action to help test logs, performance, database queries and more
$action = SayHelloAction::test();Actions can emit and listen to events:
<?php
namespace App\Actions;
use Iak\Action\Action;
use Iak\Action\EmitsEvents;
#[EmitsEvents(['hello_said'])]
class SayHelloAction extends Action
{
public function handle()
{
$result = "Hello";
$this->event('hello_said', $result);
return $result;
}
}Listen to events:
$action = SayHelloAction::make()
->on('hello_said', function ($result) {
// Do something when hello is said
Log::info("Hello said: {$result}");
})
->handle();When you have nested actions, you can use forwardEvents() to propagate events from child actions to parent classes that use the HandlesEvents trait, even if there are intermediate classes between them. This is particularly useful when services call actions and want to listen to events from those actions.
<?php
namespace App\Services;
use Iak\Action\HandlesEvents;
use Iak\Action\EmitsEvents;
#[EmitsEvents(['email_sent', 'email_failed'])]
class EmailService
{
use HandlesEvents;
public function sendWelcomeEmail($user)
{
// Call the action with forwardEvents() to propagate events to this service
SendEmailAction::make()
->forwardEvents(['email_sent'])
->handle($user);
}
}<?php
//...
(new EmailService)
->on('email_sent', function($user) {
Log::info('email sent', ['user_id' => $user->id]);
})
->sendWelcomeEmail($user);How it works:
- When
forwardEvents()is called on an action, events emitted by that action will bubble up through the call stack to the first class that uses theHandlesEventstrait - The parent class (service, action, etc.) must also declare the event in its
#[EmitsEvents(...)]attribute to receive forwarded events - Events can propagate through multiple layers of intermediate classes, as long as the ancestor class uses the
HandlesEventstrait
Forwarding specific events:
SendEmailAction::make()
->forwardEvents(['email_sent', 'email_failed'])
->handle($user);Forwarding all allowed events:
If you call forwardEvents() without arguments, all events declared in the action's #[EmitsEvents(...)] attribute will be forwarded:
SendEmailAction::make()
->forwardEvents() // Forwards all events: ['email_sent', 'email_failed']
->handle($user);<?php
use App\Actions\SayHelloAction;
it('says hello', function () {
$result = SayHelloAction::make()->handle();
expect($result)->toBe('Hello');
});
it('can fake an action', function () {
$action = SayHelloAction::fake();
expect($action)->toBeInstanceOf(MockInterface::class);
});When testing actions that call other actions, you can control which actions execute their real logic and which are mocked.
The only() method specifies which actions should execute normally. All other actions will be automatically mocked.
use App\Actions\ProcessOrderAction;
use App\Actions\CalculateTaxAction;
use App\Actions\ChargeCustomerAction;
use App\Actions\SendEmailAction;
it('only executes specific actions', function () {
ProcessOrderAction::test()
->only([ChargeCustomerAction::class, CalculateTaxAction::class])
->handle(function () {
// ChargeCustomerAction executes normally
// CalculateTaxAction executes normally
// SendEmailAction is automatically mocked
});
});You can also specify a single action:
it('allows only one action to execute', function () {
ProcessOrderAction::test()
->only(ChargeCustomerAction::class)
->handle();
});The without() method mocks specific actions, preventing them from executing their real handle() method. All other actions execute normally.
it('mocks specific actions', function () {
ProcessOrderAction::test()
->without(SendEmailAction::class)
->handle(function () {
// ChargeCustomerAction executes normally
// CalculateTaxAction executes normally
// SendEmailAction is mocked
});
});You can mock multiple actions:
it('mocks multiple actions', function () {
ProcessOrderAction::test()
->without([SendEmailAction::class, ChargeCustomerAction::class])
->handle();
});You can also specify return values for mocked actions:
it('mocks actions with custom return values', function () {
$result = ProcessOrderAction::test()
->without([
CalculateTaxAction::class => 10.50,
SendEmailAction::class => true,
])
->handle();
});The except() method is an alias for without(), providing an alternative syntax that may be more readable in certain contexts.
The queries() method allows you to record and inspect database queries executed during action execution. This can be really helpful when debugging performance issues, n+1 queries and more.
use App\Actions\ProcessOrderAction;
use Illuminate\Support\Facades\DB;
it('executes the correct database queries', function () {
ProcessOrderAction::test()
->queries(function ($queries) {
expect($queries)->toHaveCount(2);
expect($queries[0]->query)->toContain('INSERT INTO orders');
expect($queries[1]->query)->toContain('UPDATE inventory');
expect($queries[0]->action)->toBe(ProcessOrderAction::class);
})
->handle($orderData);
});To track queries for a specific nested action:
it('tracks queries from nested actions', function () {
ProcessOrderAction::test()
->queries(CalculateTaxAction::class, function ($queries) {
expect($queries)->toHaveCount(1);
expect($queries[0]->query)->toContain('SELECT');
})
->handle($orderData);
});The logs() method allows you to capture and verify log entries written during action execution:
use App\Actions\ProcessOrderAction;
use Illuminate\Support\Facades\Log;
it('logs important events', function () {
ProcessOrderAction::test()
->logs(function ($logs) {
expect($logs)->toHaveCount(2);
expect($logs[0]->level)->toBe('INFO');
expect($logs[0]->message)->toBe('Order processing started');
expect($logs[1]->level)->toBe('ERROR');
expect($logs[1]->message)->toBe('Payment failed');
expect($logs[0]->context)->toBeArray();
})
->handle($orderData);
});To track logs from a specific nested action:
it('tracks logs from nested actions', function () {
ProcessOrderAction::test()
->logs(SendEmailAction::class, function ($logs) {
expect($logs)->toHaveCount(1);
expect($logs[0]->message)->toBe('Email sent successfully');
})
->handle($orderData);
});The profile() method allows you to measure execution time, memory usage, and track memory records:
use App\Actions\ProcessOrderAction;
it('profiles action performance', function () {
ProcessOrderAction::test()
->profile(function ($profiles) {
expect($profiles)->toHaveCount(1);
expect($profiles[0]->class)->toBe(ProcessOrderAction::class);
expect($profiles[0]->duration()->totalMilliseconds)->toBeLessThan(100);
expect($profiles[0]->memoryUsed())->toBeGreaterThan(0);
})
->handle($orderData);
});You can also track memory points during execution:
it('tracks memory usage at specific points', function () {
ProcessOrderAction::test()
->profile(function ($profiles) {
$records = $profiles[0]->records();
expect($records)->toHaveCount(2);
expect($records[0]->name)->toBe('before-processing');
expect($records[1]->name)->toBe('after-processing');
})
->handle(function ($action) {
$action->recordMemory('before-processing');
// ... do work ...
$action->recordMemory('after-processing');
});
});To profile specific nested actions:
it('profiles nested actions', function () {
ProcessOrderAction::test()
->profile([CalculateTaxAction::class, ApplyDiscountAction::class], function ($profiles) {
expect($profiles)->toHaveCount(2);
expect($profiles[0]->class)->toBe(CalculateTaxAction::class);
expect($profiles[1]->class)->toBe(ApplyDiscountAction::class);
})
->handle($orderData);
});You can combine multiple testing features in a single test:
it('tracks queries, logs, and performance', function () {
ProcessOrderAction::test()
->queries(function ($queries) {
expect($queries)->toHaveCount(3);
})
->logs(function ($logs) {
expect($logs)->toHaveCount(2);
})
->profile(function ($profiles) {
expect($profiles)->toHaveCount(1);
expect($profiles[0]->duration()->totalMilliseconds)->toBeLessThan(50);
})
->handle($orderData);
});- PHP 8.2+
- Laravel 11.0+ or 12.0+
The MIT License (MIT). Please see License File for more information.
Please see CONTRIBUTING for details.
If you discover any issues or have questions, please open an issue.