Vortos
Object Store

Outbox And Promotions

Atomically adopt temporary direct-upload objects into permanent storage keys.

Outbox And Promotions

Promotion copies an uploaded temporary object to a permanent key and optionally deletes the temporary source. With outbox enabled, promotion is recorded in object_store_outbox inside the command transaction.

Command Handler Example

use Vortos\ObjectStore\Contract\DirectUploadManagerInterface;
use Vortos\ObjectStore\ValueObject\PromoteObjectRequest;

final class SubmitProfilePhotoHandler
{
    public function __construct(
        private readonly ProfileRepository $profiles,
        private readonly DirectUploadManagerInterface $uploads,
    ) {}

    public function handle(SubmitProfilePhoto $command): void
    {
        $profile = $this->profiles->get($command->userId);

        $permanentKey = sprintf(
            'assets/users/%s/avatar/%s.jpg',
            $command->userId,
            bin2hex(random_bytes(16)),
        );

        $this->uploads->promote(PromoteObjectRequest::fromKeys(
            temporaryKey: $command->temporaryKey,
            permanentKey: $permanentKey,
        ));

        $profile->changeAvatarKey($permanentKey);

        $this->profiles->save($profile);
    }
}

When invoked through the command bus, both writes commit together:

  • Domain row references the permanent key.
  • object_store_outbox row records the promotion.

If the database transaction rolls back, neither survives.

Temporary And Permanent Key Rules

PromoteObjectRequest enforces:

  • Source key must be under the temporary prefix.
  • Permanent key cannot remain under the temporary prefix.
  • Temporary prefix cannot be empty.
PromoteObjectRequest::fromKeys(
    temporaryKey: 'tmp/users/123/upload.bin',
    permanentKey: 'assets/users/123/avatar.bin',
    temporaryPrefix: 'tmp',
);

Service Choice

Use the transactional interface for domain workflows:

DirectUploadManagerInterface $uploads;

Use standalone outbox for maintenance:

StandaloneDirectUploadManagerInterface $uploads;

Use direct provider calls for diagnostics:

ImmediateDirectUploadManagerInterface $uploads;

Do not promote before domain validation

Temporary uploads are quarantine-adjacent. Validate ownership, expected content type, expected size, and domain permission before promoting into permanent prefixes.

Relay Worker

# Long-running worker (handles SIGTERM gracefully)
php bin/console vortos:object-store:relay

# Single batch and exit (for cron)
php bin/console vortos:object-store:relay --once

# Control idle sleep interval
php bin/console vortos:object-store:relay --sleep=2

Install supervisor config:

php bin/console vortos:worker:install --worker=object-store-outbox-relay

Dead-Letter Retry

When an outbox entry exhausts all delivery attempts it is marked status=dead. The relay no longer picks it up. Use the retry command to inspect and reset dead entries:

# Inspect dead entries without resetting
php bin/console vortos:object-store:outbox:retry --dry-run

# Filter by operation
php bin/console vortos:object-store:outbox:retry --dry-run --operation=put

# Filter by date range
php bin/console vortos:object-store:outbox:retry --dry-run --created-from=2026-06-01 --created-to=2026-06-02

# Reset dead entries (prompts for confirmation)
php bin/console vortos:object-store:outbox:retry --force

# Reset a specific entry by ID
php bin/console vortos:object-store:outbox:retry --id=abc-uuid --force

# Reset only a specific operation type
php bin/console vortos:object-store:outbox:retry --operation=promote --force

# Limit batch size
php bin/console vortos:object-store:outbox:retry --limit=10 --force

Resetting sets status=pending, clears attempt_count, processed_at, and last_error, and sets next_attempt_at=NULL (process immediately). The relay picks the rows up on its next poll.

Outbox Table

ColumnDescription
statuspendingdone (success) or dead (permanently failed).
attempt_countDelivery attempt count.
last_errorError message from the most recent failed attempt.
next_attempt_atScheduled time for the next delivery attempt (exponential backoff).
processed_atSet when the row is successfully delivered or marked dead.
domain_event_idOptional correlation ID for idempotent writes from domain events.

Why A Separate Outbox Table?

Object-store operations have different payloads and retry semantics than email or domain events. A failed copyObject retry is operationally different from a failed SES send or Kafka publish. Separate tables keep indexes, workers, monitoring, and retention policies clean.

Atomicity is still one transaction because the UnitOfWork owns the database transaction across all participating package writers.

On this page