iak/action

Simple actions for Laravel

Fund package maintenance!
Isak Berglind

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 6

Watchers: 0

Forks: 0

Open Issues: 1

pkg:composer/iak/action

1.3.0 2025-11-04 09:29 UTC

README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

A simple way to organize your business logic in Laravel applications.

Installation

composer require iak/action

Basic Usage

Creating an Action

<?php

namespace App\Actions;

use Iak\Action\Action;

class SayHelloAction extends Action
{
    public function handle()
    {
        return "Hello";
    }
}

Using an Action

<?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);
    }
}

Static Methods

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();

Events

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();

Forwarding Events

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 the HandlesEvents trait
  • 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 HandlesEvents trait

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);

Testing

Basic Testing

<?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);
});

Mocking Actions in Tests

When testing actions that call other actions, you can control which actions execute their real logic and which are mocked.

The only() Method

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

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

The except() method is an alias for without(), providing an alternative syntax that may be more readable in certain contexts.

Testing Database Queries

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);
});

Testing Logs

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);
});

Profiling Actions

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);
});

Combining Features

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);
});

Requirements

  • PHP 8.2+
  • Laravel 11.0+ or 12.0+

License

The MIT License (MIT). Please see License File for more information.

Contributing

Please see CONTRIBUTING for details.

Credits

Support

If you discover any issues or have questions, please open an issue.