Skip to content
Guide

Run background and scheduled work

Declare a handler, enqueue one operation, and verify its durable result.

Run background and scheduled work

Use AppWorkContribution and AppWorkDispatcher, not an App-owned queue worker or server cron. Prepare the tenant-owned Example Resource from Create development data. This synthetic example creates one stable record; it is not a user-delegated business operation.

Implement and declare work

Create src/Work/EnsureExample.php:

Code example
PHP
<?php

declare(strict_types=1);

namespace Nexia\Apps\Acme\Workshop\Work;

use InvalidArgumentException;
use Nexia\Apps\Acme\Workshop\Models\Example;
use Nexia\AsyncWork\AppWorkInvocation;
use Nexia\AsyncWork\Contracts\AppWorkHandler;

final class EnsureExample implements AppWorkHandler
{
    public function handle(AppWorkInvocation $invocation): void
    {
        if ($invocation->key !== 'workshop.example.ensure' || $invocation->payload !== []) {
            throw new InvalidArgumentException('Unexpected work payload.');
        }
        Example::firstOrCreate(
            ['public_id' => '018d4f35-84ed-4ce4-a421-277f7d872601'],
            ['name' => 'Background example'],
        );
    }
}

Add src/Contribution/WorkshopWork.php. The generated Manifest discovers it:

Code example
PHP
<?php

declare(strict_types=1);

namespace Nexia\Apps\Acme\Workshop\Contribution;

use Nexia\Apps\Acme\Workshop\Work\EnsureExample;
use Nexia\AsyncWork\AppWorkDefinition;
use Nexia\AsyncWork\Contracts\AppWorkContribution;

final class WorkshopWork implements AppWorkContribution
{
    public static function appWork(): array
    {
        return [new AppWorkDefinition('workshop.example.ensure', EnsureExample::class)];
    }
}

Request execution

Add this body to a POST action inside your generated authenticated App route/controller. Keep Request validation and the generated Resource Policy; do not allow a caller to choose a work key or handler class. The empty payload here is intentional:

Code example
PHP
use Nexia\AsyncWork\Contracts\AppWorkDispatcher;

$this->authorize('create', \Nexia\Apps\Acme\Workshop\Models\Example::class);
app(AppWorkDispatcher::class)->dispatch(
    'workshop.example.ensure',
    [],
    'workshop.example.ensure.initial',
);
return response()->json(['accepted' => true], 202);

This fixed idempotency key identifies one initialization request. A real operation needs a persisted operation identity; retries reuse it, while genuinely different operations use different keys. Work may execute more than once. The handler's stable unique record protects this example from duplicate effects.

The sandbox dispatcher sends after an App transaction commits when called inside one. This is not a transactional outbox: failure between commit and enqueue needs a durable App operation record and retry with the same key. Do not report the domain operation completed merely because enqueue returned.

Add a schedule only when needed

Add the interface and method to the same contribution. Core owns triggering eligible installations; the App supplies a five-field cron expression and bounded payload:

Code example
PHP
use Nexia\AsyncWork\AppWorkSchedule;
use Nexia\AsyncWork\Contracts\AppWorkScheduleContribution;

// Also implement AppWorkScheduleContribution on WorkshopWork.
public static function appWorkSchedules(): array
{
    return [new AppWorkSchedule(
        key: 'workshop.example.hourly',
        workKey: 'workshop.example.ensure',
        cron: '0 * * * *',
    )];
}

Scheduled work has no interactive user merely because someone installed the App. Do not use it to bypass user permissions or guess an organization. AppWorkInvocation supplies executionId, key, attempt, and payload; it is not a user or Legal Entity DTO. Domain work must resolve its documented authority and targets explicitly. Platform callback requirements belong in AppWorkDefinition.platformCallbacks; arbitrary callback names are not granted access.

Verify execution and recovery

  1. Synchronize with project nexia dev and wait for readiness; assign Example create/read permissions.
  2. Call the protected POST once, then inspect Examples until the background record appears. A 202 alone proves only acceptance.
  3. Retry the same request, then repeat handler delivery in an isolated test. One record and its edited name must remain.
  4. Test a denied caller, a rejected payload, and handler failure. Never turn a failed operation into success.
  5. For scheduling, observe a due execution while the runtime is available. Registration alone does not prove that the managed scheduler ran; report the operation identity to support when no execution occurs.

For reactions to committed business events, use Events and asynchronous work.

Source of truth: docs/developers/content/en/building-apps/background-work.md