Skip to content

Job Actors

Job actors let you invoke a Laravel Job as a child actor — ideal for single-step async operations that don't need a full state machine.

Config Syntax

Managed Job (with @done/@fail)

The parent waits for the job to complete and routes @done or @fail:

php
'sending_email' => [
    'job'      => SendWelcomeEmailJob::class,
    'input'     => ['email', 'name'],
    '@done'    => 'email_sent',
    '@fail'    => 'email_failed',
    '@timeout' => ['target' => 'timed_out', 'after' => 300],
],

Fire-and-Forget Job

No @done/@fail — the job is dispatched and the parent transitions immediately to target:

php
'logging' => [
    'job'    => AuditLogJob::class,
    'input'   => ['action', 'userId'],
    'target' => 'next_state',
],

The parent does not track the job's output. If the job fails, it goes to Laravel's failed_jobs table.

Returning Output

Jobs that implement ReturnsOutput can return data to the parent:

php
use Illuminate\Contracts\Queue\ShouldQueue;
use Tarfinlabs\EventMachine\Contracts\ReturnsOutput;

class SendWelcomeEmailJob implements ShouldQueue, ReturnsOutput
{
    public function __construct(
        public readonly string $email,
        public readonly string $name,
    ) {}

    public function handle(): void
    {
        // ... send email ...
        $this->messageId = 'msg_abc123';
    }

    public function output(): array
    {
        return ['messageId' => $this->messageId];
    }
}

The parent receives the result via ChildMachineDoneEvent->output():

php
use Tarfinlabs\EventMachine\ContextManager;
use Tarfinlabs\EventMachine\Behavior\ActionBehavior;
use Tarfinlabs\EventMachine\Behavior\ChildMachineDoneEvent;

class StoreEmailOutputAction extends ActionBehavior
{
    public function __invoke(ContextManager $context, ChildMachineDoneEvent $event): void
    {
        $context->set('messageId', $event->output('messageId'));
    }
}

Jobs that do not implement ReturnsOutput return an empty output ([]).

Typed Output with MachineOutput

Jobs can also return a MachineOutput DTO for typed contracts:

php
use Illuminate\Contracts\Queue\ShouldQueue;
use Tarfinlabs\EventMachine\Contracts\ReturnsOutput;
use Tarfinlabs\EventMachine\Behavior\MachineOutput;

class EmailOutput extends MachineOutput
{
    public function __construct(
        public readonly string $messageId,
        public readonly string $status,
    ) {}
}

class SendWelcomeEmailJob implements ShouldQueue, ReturnsOutput
{
    public function __construct(
        public readonly string $email,
    ) {}

    public function handle(): void
    {
        $this->messageId = 'msg_abc123';
    }

    public function output(): EmailOutput
    {
        return new EmailOutput(
            messageId: $this->messageId,
            status: 'sent',
        );
    }
}

Returning Failure Context

By default, when a job throws an exception, only $exception->getMessage() and $exception->getCode() are available to @fail guards. For structured error data (error codes, retry hints, categories), implement ProvidesFailure:

php
use Illuminate\Contracts\Queue\ShouldQueue;
use Tarfinlabs\EventMachine\Contracts\ProvidesFailure;

class ConfirmPinJob implements ShouldQueue, ProvidesFailure
{
    public function __construct(
        public readonly string $pin,
    ) {}

    public function handle(): void
    {
        // ... may throw FindeksException with error code E311
    }

    public static function failure(\Throwable $exception): array
    {
        if ($exception instanceof FindeksException) {
            return [
                'errorCode' => $exception->getFindeksErrorCode(),
                'retryable' => $exception->isRetryable(),
            ];
        }

        return ['errorCode' => 'UNKNOWN'];
    }
}

The returned array becomes available via $event->output() in @fail guards and actions:

php
use Tarfinlabs\EventMachine\ContextManager;
use Tarfinlabs\EventMachine\Behavior\GuardBehavior;
use Tarfinlabs\EventMachine\Behavior\ChildMachineFailEvent;

class IsPinRetryableGuard extends GuardBehavior
{
    public function __invoke(ContextManager $context, ChildMachineFailEvent $event): bool
    {
        return in_array($event->output('errorCode'), ['E311', 'E116', 'E117'], true);
    }
}

ChildMachineFailEvent API

AccessorSourceAlways Available
errorMessage()$exception->getMessage()Yes
errorCode()$exception->getCode()Yes
output(?string $key)ProvidesFailure::failure()Only with contract
childMachineId()Job tracking IDYes
childMachineClass()Job FQCNYes

ReturnsOutput vs ProvidesFailure

ReturnsOutput populates $event->output() on @done. ProvidesFailure populates $event->output() on @fail. They complement each other -- a job can implement both.

Machine vs Job

Aspectmachinejob
StatefulYes (multiple states)No (single step)
ContextOwn ContextManagerData from input
Lifecycle@done / @fail / @timeout@done / @fail / @timeout
Fire-and-forgetYes (omit @done, requires queue)Yes (target key)
Outputoutput key on final stateReturnsOutput interface
TestingMachine::fake()Queue::fake() + ChildJobJob
Use caseComplex stateful workflowsSingle-step async operations

Context Transfer

The input key works the same way as machine delegation — same three formats:

php
// Same-name: ['email'] → job receives email from parent
// Rename: ['recipient' => 'email'] → job.recipient = parent.email
// Closure: fn(ContextManager $ctx) => ['to' => $ctx->get('email')]

Validation Rules

ConfigResult
job + machineInvalidStateConfigException: mutually exclusive
job + type: parallelInvalidStateConfigException: not supported
job without @done or targetInvalidStateConfigException: must define one
job + @done + targetInvalidStateConfigException: ambiguous
job + @doneOK: managed job
job + targetOK: fire-and-forget

The job class itself is validated at dispatch time. InvalidJobClassException is thrown if the class does not exist or does not have a handle() method.

Queue Configuration

Jobs support the same queue options as machine delegation:

php
'processing' => [
    'job'        => ProcessDataJob::class,
    'input'       => ['data'],
    'queue'      => 'heavy',
    'connection' => 'redis',
    '@done'      => 'processed',
],

Testing Job Actors

php
use Illuminate\Support\Facades\Queue;
use Tarfinlabs\EventMachine\Jobs\ChildJobJob;

Queue::fake();

OrderMachine::test()
    ->send('SEND_NOTIFICATION')
    ->assertState('notified');

Queue::assertPushed(ChildJobJob::class, function (ChildJobJob $job): bool {
    return $job->jobClass === SendEmailJob::class;
});

Testing Job Completion Routing

To test @done/@fail routing without running jobs, use simulateChildDone() — the same method used for machine delegation:

php
Queue::fake();

MyMachine::test()
    ->withoutPersistence()
    ->send('START')
    ->assertState('processing')
    ->simulateChildDone(MyJob::class, output: ['status' => 'ok'])
    ->assertState('completed');

This works because job actors and machine children share the same completion routing infrastructure.

Queue::fake() vs simulateChildDone()

GoalTool
Verify job was dispatchedQueue::fake() + Queue::assertPushed(ChildJobJob::class)
Verify dispatch dataQueue::assertPushed(ChildJobJob::class, fn($job) => ...)
Test @done routingsimulateChildDone(MyJob::class, output: [...])
Test @fail routingsimulateChildFail(MyJob::class, errorMessage: '...')
Test @timeout handlingsimulateChildTimeout(MyJob::class)
Full pipeline with HorizonLocalQA tests

Full Testing Guide

See Testing Job Actors for more examples.

Released under the MIT License.