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_outboxrow 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=2Install supervisor config:
php bin/console vortos:worker:install --worker=object-store-outbox-relayDead-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 --forceResetting 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
| Column | Description |
|---|---|
status | pending → done (success) or dead (permanently failed). |
attempt_count | Delivery attempt count. |
last_error | Error message from the most recent failed attempt. |
next_attempt_at | Scheduled time for the next delivery attempt (exponential backoff). |
processed_at | Set when the row is successfully delivered or marked dead. |
domain_event_id | Optional 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.