Draft-Then-Publish Pattern for Transactional Safety with External Systems

📖 3 minutes read

When your Laravel app integrates with external systems—third-party APIs, CMS platforms via CLI, payment gateways—standard database transactions can’t protect you. If your Laravel transaction succeeds but the external call fails (or vice versa), you’re left with inconsistent state across systems.

The solution: create external records in a draft or pending state, complete all Laravel operations within a transaction, and only promote the external resource to published/active after the transaction commits.

The Pattern

public function handle(): void {
    $createdExternalIds = [];
    
    foreach ($this->items as $item) {
        try {
            // Step 1: Create in external system as DRAFT
            $externalId = $this->createExternalResource($item, status: 'draft');
            $createdExternalIds[] = $externalId;
            
            // Step 2: Laravel DB transaction
            DB::transaction(function () use ($item, $externalId) {
                $record = Record::create([
                    'external_id' => $externalId,
                    'title' => $item['title'],
                    'status' => 'pending'
                ]);
                
                $record->tags()->sync($item['tags']);
                // ... other database operations
            });
            
            // Step 3: Success! Publish the external resource
            $this->publishExternalResource($externalId);
            
        } catch (Throwable $e) {
            report($e);
            $this->error(sprintf('%s: %s', $item['title'], $e->getMessage()));
            continue; // Keep processing other items
        }
    }
}

Why This Works

If the Laravel transaction fails for any reason—validation, constraint violation, or application error—the external record remains in draft state. You can clean it up later, retry the import, or manually investigate. Nothing went live that shouldn’t have.

If the external API call succeeds but Laravel fails, you still have a draft record in the external system. Your database stays clean.

Only when both sides succeed does the external record become visible to users.

When to Use This

  • Content management systems: Create posts/pages as drafts, link them in Laravel, then publish.
  • Third-party APIs: If the API supports draft/pending states (Stripe payment intents, Shopify draft orders, etc.).
  • Batch imports: Processing hundreds or thousands of records where you want partial success rather than all-or-nothing.

Implementation Tips

1. Abstract the external calls into methods

private function createExternalResource(array $data, string $status): string
{
    $result = Http::post('https://api.example.com/resources', [
        'title' => $data['title'],
        'status' => $status,
    ]);
    
    return $result->json('id');
}

private function publishExternalResource(string $id): void
{
    Http::patch("https://api.example.com/resources/{$id}", [
        'status' => 'published'
    ]);
}

2. Use continue on errors to process remaining items

In batch operations, don’t let one failure kill the entire import. Log it, report it, move on.

3. Consider cleanup jobs for orphaned drafts

If your process crashes halfway through, you might have draft records in the external system with no corresponding Laravel record. Schedule a daily cleanup job that checks for drafts older than 24 hours and deletes them.

Real-World Example: CMS Integration

Let’s say you’re building a product catalog in Laravel that syncs to a headless CMS for public display. Each Laravel Product needs a corresponding CMS post.

DB::transaction(function () use ($productData) {
    // Create CMS post as draft
    $cmsPostId = $this->cms->createPost([
        'title' => $productData['name'],
        'status' => 'draft',
        'content' => $productData['description']
    ]);
    
    // Create Laravel product
    $product = Product::create([
        'name' => $productData['name'],
        'sku' => $productData['sku'],
        'cms_post_id' => $cmsPostId,
    ]);
    
    $product->categories()->sync($productData['category_ids']);
    
    // Publish the CMS post
    $this->cms->publishPost($cmsPostId);
});

If anything inside the transaction fails—duplicate SKU, missing category, whatever—the CMS post stays as a draft. Your public site never shows broken data.

When You Can’t Use Drafts

Not every external system supports draft states. In those cases:

  • Do the Laravel work first, then make the external call after the transaction commits.
  • Use queued jobs for the external call—if it fails, retry logic kicks in.
  • Store the external state in a pending column in Laravel, update it to synced after success.

But if the external system does support drafts or pending states, use them. It’s the cleanest way to maintain consistency across both systems.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *