Category: Laravel

  • Debug Laravel Cache Issues with Conditional Cache Parameters

    When you’re debugging data inconsistencies in Laravel and suspect caching might be the culprit, here’s a pattern that can save hours of investigation.

    The Problem

    You have a calculator class that computes prices. Sometimes it returns stale data from Redis, sometimes fresh data from the database. The bug is intermittent and hard to reproduce. How do you prove cache is the issue?

    The Debug Pattern

    Add a cache parameter to your method and compare both results side-by-side:

    // Test with cache enabled (default behavior)
    $cachedPrice = $calculator->calculate($orderId);
    
    // Test with cache explicitly disabled  
    $freshPrice = $calculator->calculate($orderId, use_cache: false);
    
    if ($cachedPrice !== $freshPrice) {
        // Cache poisoning confirmed
    }
    

    This immediately tells you if cache is the problem without clearing cache globally or adding debug logging everywhere.

    The Implementation

    Add a boolean parameter to methods that might cache data:

    class PriceCalculator
    {
        public function calculate(
            int $orderId,
            bool $use_cache = true
        ): float {
            if (!$use_cache) {
                return $this->calculateFromDatabase($orderId);
            }
    
            return Cache::remember(
                "order_price_{$orderId}",
                now()->addHours(1),
                fn() => $this->calculateFromDatabase($orderId)
            );
        }
    
        private function calculateFromDatabase(int $orderId): float
        {
            // Fresh calculation from DB
            return Order::find($orderId)->items->sum('total');
        }
    }
    

    Use Named Parameters

    PHP 8+ named parameters make cache-disabling calls self-documenting:

    // Clear intent - this call bypasses cache
    $price = $calculator->calculate($orderId, use_cache: false);
    
    // vs the old way (less obvious)
    $price = $calculator->calculate($orderId, false);
    

    When to Disable Cache

    Keep cache enabled by default (performance), but disable it for operations where stale data causes bugs:

    class OrderRepository
    {
        public function create(array $data): Order
        {
            // Order creation must use fresh prices
            $price = $this->calculator->calculate(
                $data['product_id'],
                use_cache: false
            );
    
            return Order::create([
                'product_id' => $data['product_id'],
                'price' => $price,
            ]);
        }
    }
    

    Takeaway

    When you suspect cache issues, don’t guess. Add a cache parameter, compare results, and you’ll know instantly if cache is the problem. Then you can fix it surgically without breaking performance elsewhere.

  • Building API Transformers with Multiple Interfaces in Laravel

    Need to transform data between two different API formats? Implement both interfaces in a single service class. This pattern creates a bridge that speaks both languages.

    The Problem

    You’re building a mobile app that expects data in Format A (clean REST), but your data source provides Format B (legacy XML-RPC or a different REST structure). You need to translate between them without rewriting either side.

    The Solution: Dual Interface Implementation

    Create a transformer service that implements both interfaces:

    <?php
    
    namespace App\Services;
    
    use App\Contracts\SourceApiInterface;  // What you're fetching from
    use App\Contracts\TargetApiInterface;  // What clients expect
    
    class DataTransformer implements SourceApiInterface, TargetApiInterface
    {
        private HttpClient $client;
        
        public function __construct(HttpClient $client)
        {
            $this->client = $client;
        }
    
        // ===================================================
        // SOURCE API METHODS (fetch from external API)
        // ===================================================
        
        public function fetchProducts(array $filters): array
        {
            // Fetch from external API
            $response = $this->client->get('/v1/items', $filters);
            return $response->json();
        }
        
        public function fetchCategories(): array
        {
            $response = $this->client->get('/v1/taxonomies');
            return $response->json();
        }
    
        // ===================================================
        // TARGET API METHODS (transform + expose to clients)
        // ===================================================
        
        public function getItems(string $category, int $limit = 20): array
        {
            // Map category to source filter
            $sourceFilters = ['taxonomy_id' => $this->mapCategory($category)];
            
            // Fetch using source API method
            $sourceData = $this->fetchProducts($sourceFilters);
            
            // Transform to target format
            return $this->transformToItems($sourceData);
        }
        
        public function getItemDetails(string $id): array
        {
            $sourceData = $this->fetchProduct($id);
            return $this->transformToItemDetails($sourceData);
        }
    
        // ===================================================
        // PRIVATE TRANSFORMATION LOGIC
        // ===================================================
        
        private function transformToItems(array $sourceProducts): array
        {
            return array_map(function ($product) {
                return [
                    'id' => $product['item_id'],
                    'title' => $product['name'],
                    'price' => $product['cost'] / 100,  // cents to dollars
                    'stock' => $product['inventory']['available'],
                ];
            }, $sourceProducts);
        }
        
        private function mapCategory(string $targetCategory): int
        {
            // Map client-facing category names to source taxonomy IDs
            return match($targetCategory) {
                'electronics' => 15,
                'books' => 8,
                'clothing' => 23,
                default => 1,
            };
        }
    }
    

    How It Works

    1. Source interface methods (fetchProducts, fetchCategories) handle raw API calls to the external service
    2. Target interface methods (getItems, getItemDetails) expose the clean API your clients expect
    3. Transformation happens in private methods that map fields, rename keys, convert units, etc.

    The transformer becomes a bidirectional adapter — it speaks the source API internally and the target API publicly.

    When to Use This Pattern

    • Wrapping legacy APIs with modern REST interfaces
    • Multi-source data aggregation where you combine APIs into one unified interface
    • API versioning — v2 interface transforms data from v1 endpoints
    • SDK replacement — your app expects Reddit API format, but you’re pulling from WordPress

    Benefits

    Type safety: Both interfaces enforce contracts at compile time

    Testability: Mock either interface independently

    Single responsibility: One class, one job — transform between two formats

    Swappability: Replace the source API without changing client code (dependency inversion)

    Real-World Example

    A mobile app needed a Reddit-style API (posts, comments, upvotes) but the content lived in WordPress. The transformer implemented both WordPressApiInterface and RedditApiInterface:

    • fetchPosts() called WordPress REST API
    • getHot() transformed WP posts into Reddit listing format
    • Mobile app saw clean Reddit JSON, never touched WordPress directly

    Key insight: When formats overlap, the target format wins. The transformer hides complexity from clients.

  • Laravel 12: Adding API Routes the Official Way

    Laravel 12 removed routes/api.php from the default skeleton to keep new projects lean. When you’re ready to build APIs, here’s the modern way to add API support.

    The Old Way (Laravel 10 and Earlier)

    Every fresh Laravel install came with routes/api.php pre-registered with the /api prefix. Most projects never used it, but it was always there.

    The Laravel 12 Way

    Run one command:

    php artisan install:api
    

    This installs:

    • routes/api.php with automatic /api/* prefix
    • Route registration in bootstrap/app.php
    • Laravel Sanctum for token-based authentication
    • Sanctum database migrations

    After running it, any route you add to routes/api.php is automatically accessible at /api/{route}.

    What Gets Generated

    Inside bootstrap/app.php, you’ll see:

    return Application::configure(basePath: dirname(__DIR__))
        ->withRouting(
            web: __DIR__.'/../routes/web.php',
            api: __DIR__.'/../routes/api.php',  // ← Added by install:api
            commands: __DIR__.'/../routes/console.php',
            health: '/up',
        )
        // ...
    

    It’s the same structure as before, just opt-in instead of default.

    Manual Alternative (No Sanctum)

    If you don’t need Sanctum (e.g., public APIs or custom auth), you can manually add API routes without installing the full package:

    // bootstrap/app.php
    return Application::configure(basePath: dirname(__DIR__))
        ->withRouting(
            web: __DIR__.'/../routes/web.php',
            api: __DIR__.'/../routes/api.php',  // ← Add manually
            commands: __DIR__.'/../routes/console.php',
            health: '/up',
        )
    

    Then create routes/api.php yourself:

    <?php
    
    use Illuminate\Support\Facades\Route;
    
    Route::get('/status', function () {
        return ['status' => 'ok'];
    });
    

    Routes in this file are automatically prefixed with /api.

    Why the Change?

    Most Laravel apps don’t need API routes. Removing them from the skeleton reduces decision fatigue for beginners and clutter for everyone else.

    When you do need APIs, install:api gives you the full setup in one step instead of hunting for documentation.

    Bottom line: Laravel 12 defaults to simple, adds complexity on demand.

  • Tracing Frontend Form Fields to Backend Database Columns

    A user reports “the status field isn’t saving.” You need to find where that field is stored in the database. Here’s the fastest way to trace from frontend label to backend column in a Laravel + Vue.js app.

    The Workflow

    1. Start with the label text

    User says “status field.” Search your Vue components for that label:

    grep -r "Status" resources/js/components/

    Find the component:

    <template>
      <div>
        <label>Status</label>
        <select v-model="form.order_status">
          <option value="pending">Pending</option>
          <option value="completed">Completed</option>
        </select>
      </div>
    </template>

    2. Check the v-model binding

    v-model="form.order_status" tells you the property name. Check what gets sent to the backend:

    // In the same component or parent
    methods: {
      submitForm() {
        axios.post('/api/orders', this.form)
          .then(/* ... */)
      }
    }

    The POST payload includes order_status: 'pending'.

    3. Match to the Laravel controller

    Check your API route. The request hits OrderController@store:

    public function store(Request $request)
    {
        $validated = $request->validate([
            'order_status' => 'required|string',
            // ...
        ]);
    
        Order::create($validated);
    }

    4. Check the database column

    In most Laravel apps, the input name matches the column name. Verify in your migration or model:

    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->string('order_status');  // ← Found it
        // ...
    });

    Full path: Status label → form.order_status (Vue) → order_status (request) → order_status (column)

    When Names Don’t Match

    Sometimes frontend and backend use different names:

    <!-- Frontend: display_name -->
    <input v-model="form.display_name">
    // Backend renames it
    public function store(Request $request)
    {
        User::create([
            'name' => $request->input('display_name'),
        ]);
    }

    Check $fillable or create() calls to see the mapping.

    Bonus: Use Browser DevTools

    Open Network tab, submit the form, inspect the POST payload. You’ll see exactly what key names are sent:

    {
      "order_status": "pending",
      "customer_email": "[email protected]"
    }

    Match those keys to your database columns.

    Why This Matters

    When debugging form issues, you need the column name to:

    • Check database constraints (NOT NULL, unique, etc.)
    • Read logs filtered by that field
    • Write queries to inspect actual data
    • Verify fillable/guarded settings

    This workflow gets you from “the status field” (user language) to orders.order_status (database reality) in under a minute.

  • Markdown Links in Database Comments for Better Admin UX

    Database comment fields are often filled with plain reference IDs that require manual copying and pasting into search bars or URLs. Here’s a tiny UX improvement that turns those plain IDs into clickable links.

    The Problem

    Your admin panel lets users add notes to records. Users often reference related resources by ID:

    // Admin adds comment like this:
    "Related to order #12345"
    "See invoice #67890 for details"

    Later, someone reads that comment and has to manually navigate to the orders page, search for 12345, etc. Wasted clicks.

    The Solution: Markdown Links

    Store markdown-formatted links in your comment field instead:

    // When saving the comment:
    $comment = sprintf(
        '[Order #%d](%s)',
        $order->id,
        route('admin.orders.show', $order)
    );
    
    $record->notes = $comment;
    $record->save();

    Result in the database:

    [Order #12345](/admin/orders/12345)

    Display It in the UI

    Most admin panels render comments as plain text. Parse it with a markdown library on display:

    use League\CommonMark\CommonMarkConverter;
    
    $converter = new CommonMarkConverter();
    $html = $converter->convert($record->notes);
    
    // In your Blade template:
    {!! $html !!}

    Now your admins see clickable links instead of plain IDs.

    When To Use This

    • Audit logs: Link to the user who made the change
    • Error reports: Link to the transaction or session that failed
    • Notes fields: Link to related records
    • Status changes: Link to the approval or rejection action

    Quick Wins

    1. Start with one high-traffic admin page
    2. Add markdown rendering to the comments display
    3. Wrap IDs in markdown links when creating new comments
    4. Watch your team stop asking “where do I find order 12345?”

    Small change. Big quality of life improvement.

  • Exponential Backoff: Why attempts⁴ and attempts⁵ Are Not the Same

    Exponential Backoff: Why attempts⁴ and attempts⁵ Are Not the Same

    Your queue job hits a rate limit. You need exponential backoff. But which formula?

    Here are two real patterns I’ve seen coexist in the same codebase — and the differences matter more than you’d think.

    Pattern A: attempts⁴ + Jitter

    catch (RateLimitedException $e) {
        $this->release(($this->attempts() ** 4) + random_int(0, 40));
    }

    Pattern B: Base Delay + attempts⁵

    protected function getDelay(): int
    {
        return 30 + $this->attempts() ** 5;
    }
    
    catch (RateLimitedException $e) {
        $this->release($this->getDelay());
    }

    The Numbers Tell the Story

    Let’s calculate the actual delays:

    Pattern A (attempts⁴ + random(0, 40)):

    • Attempt 1: 1–41 seconds
    • Attempt 2: 16–56 seconds
    • Attempt 3: 81–121 seconds (~2 min)
    • Attempt 4: 256–296 seconds (~5 min)
    • Attempt 5: 625–665 seconds (~11 min)

    Pattern B (30 + attempts⁵):

    • Attempt 1: 31 seconds
    • Attempt 2: 62 seconds
    • Attempt 3: 273 seconds (~4.5 min)
    • Attempt 4: 1,054 seconds (~17.5 min)
    • Attempt 5: 3,155 seconds (~52 min)

    When to Use Which

    Pattern A is for user-facing work. Order processing, payment confirmations, notification delivery — anything where a customer is waiting. The lower exponent (⁴ vs ⁵) keeps retry times reasonable. The random jitter prevents thundering herd problems when dozens of jobs fail simultaneously and would otherwise all retry at the exact same second.

    Pattern B is for background synchronization. Data imports, inventory syncs, report generation — work that can wait. The higher exponent (⁵) creates a much steeper curve that backs off aggressively. The 30-second base floor means you never retry immediately, giving the remote service real breathing room.

    Why Jitter Matters

    Pattern A’s random_int(0, 40) isn’t cosmetic. Without it, if 50 jobs hit a rate limit at the same time, they all retry at exactly attempts⁴ seconds later — probably hitting the rate limit again. Jitter spreads them across a 40-second window.

    Pattern B skips jitter because background jobs are already spread out. They don’t pile up the same way user-triggered actions do.

    The Takeaway

    Don’t just copy the first backoff formula you find. Think about:

    1. Who’s waiting? Users → lower exponent, faster retries
    2. How many concurrent failures? Many → add jitter
    3. How sensitive is the API? Very → higher exponent, base delay floor

    Pick the curve that matches your use case. One size doesn’t fit all, even within the same application.

  • Laravel Queued Event Listeners Need InteractsWithQueue for Retries

    Laravel Queued Event Listeners Need InteractsWithQueue for Retries

    You implement ShouldQueue on your event listener, add a catch block for rate limit exceptions, and write $this->release(60) to retry after a delay. Looks right. Ship it.

    Then it blows up in production: “Call to undefined method release()”.

    The Missing Piece

    When a Laravel queued event listener implements ShouldQueue, it gets wrapped in a CallQueuedListener job under the hood. But the listener class itself doesn’t automatically get queue interaction methods like release(), attempts(), or delete().

    You need the InteractsWithQueue trait:

    use Illuminate\Contracts\Queue\ShouldQueue;
    use Illuminate\Queue\InteractsWithQueue;
    
    class ProcessOrderUpdate implements ShouldQueue
    {
        use InteractsWithQueue;
    
        public function handle(OrderStatusChanged $event): void
        {
            try {
                $this->processor->process($event->order);
            } catch (RateLimitedException $e) {
                // Without InteractsWithQueue, this line crashes
                $this->release(($this->attempts() ** 4) + random_int(0, 40));
            }
        }
    }

    Why It’s Easy to Miss

    Queued jobs (extending Illuminate\Bus\Queueable) typically include InteractsWithQueue in their boilerplate. It’s so common you never think about it. But queued event listeners are a different class hierarchy — they implement the interface without the trait.

    The confusing part: everything works without the trait until you try to call release() or attempts(). Your listener queues fine, executes fine, and only fails when you need retry control.

    The Fix

    Simple rule: if your queued listener needs to control its own retry behavior — releasing back to the queue, checking attempt count, or deleting itself — add use InteractsWithQueue.

    Without it, you have a listener that can be queued but can’t manage its own lifecycle. With it, you get the full toolkit:

    • $this->release($delay) — put it back on the queue
    • $this->attempts() — how many times it’s been tried
    • $this->delete() — remove it from the queue

    Check your queued listeners. If any of them catch exceptions and try to retry, make sure they have the trait. The error won’t surface until that catch block actually executes — which is usually production, under load, at the worst possible time.

  • Exception Hierarchies: Control Laravel Queue Retry Behavior

    Here’s a scenario every Laravel developer hits eventually: your queue job integrates with a third-party API, and that API starts returning errors. Your job dutifully retries 5 times, backs off, and eventually fails. Multiply that by thousands of jobs, and suddenly your queue is clogged with doomed retries.

    The problem? Not all errors deserve retries.

    The Setup

    Imagine a job that syncs inventory from an external REST API:

    class SyncInventoryJob implements ShouldQueue
    {
        public $tries = 5;
        public $backoff = [10, 30, 60, 120, 300];
    
        public function handle()
        {
            try {
                $response = $this->apiClient->fetchInventory($this->productId);
                $this->processResponse($response);
            } catch (RateLimitedException $e) {
                $this->release($e->retryAfter());
            } catch (TemporarilyUnavailableException $e) {
                $this->delete();
                Log::warning("API unavailable for product {$this->productId}, removing from queue");
            } catch (\Exception $e) {
                // Generic retry — but should we?
                throw $e;
            }
        }
    }

    The catch blocks form a hierarchy: rate-limited gets a smart release, temporarily unavailable gets deleted, and everything else retries via the default mechanism.

    The Bug

    The API client was throwing a generic ApiException for every failure — including vendor-side database errors that would never self-resolve. These fell through to the generic \Exception catch, triggering 5 retries each.

    With 4,000 affected products, that’s 20,000 wasted queue attempts hammering a broken endpoint.

    The Fix

    Map vendor-side errors to your existing exception hierarchy:

    class ApiClient
    {
        public function fetchInventory(string $productId): array
        {
            $response = Http::get("{$this->baseUrl}/inventory/{$productId}");
    
            if ($response->status() === 429) {
                throw new RateLimitedException(
                    retryAfter: $response->header('Retry-After', 60)
                );
            }
    
            if ($response->serverError()) {
                $body = $response->body();
    
                // Vendor-side errors that WE can't fix
                if (str_contains($body, 'Internal Server Error')
                    || str_contains($body, 'database timeout')) {
                    throw new TemporarilyUnavailableException(
                        "Vendor-side error: {$response->status()}"
                    );
                }
            }
    
            if ($response->failed()) {
                throw new ApiException("API error: {$response->status()}");
            }
    
            return $response->json();
        }
    }

    The key insight: TemporarilyUnavailableException already existed in the job’s catch hierarchy. We just needed the API client to throw it for the right situations. No job changes required.

    The Pattern

    Design your exception hierarchy around what the caller should do, not what went wrong:

    • RateLimitedException → release with delay (their problem, temporary)
    • TemporarilyUnavailableException → delete, don’t retry (their problem, not our concern)
    • InvalidConfigException → fail permanently (our problem, needs code fix)
    • Generic Exception → retry with backoff (unknown, worth trying)

    When a new error type appears, you don’t change the job — you classify it into the right exception type at the source.

    The takeaway: Exception hierarchies in queue jobs aren’t just about catching errors. They’re a retry policy DSL. Each exception class encodes a decision about what to do next. Make that decision at the API client level, and your jobs stay clean.

  • The SerializesModels Trap: Why Your Laravel Job Retries Never Run

    If you’ve ever set $tries and $backoff on a Laravel queue job and wondered why they’re completely ignored when a model goes missing, you’ve hit the SerializesModels trap.

    The Problem

    When a job uses the SerializesModels trait, Laravel stores just the model’s ID in the serialized payload. When the job gets picked up by a worker, Laravel calls firstOrFail() to restore the model before your handle() method ever runs.

    If that model was deleted between dispatch and execution, firstOrFail() throws a ModelNotFoundException. This exception happens during deserialization — outside the retry/backoff lifecycle entirely. Your carefully configured retry logic never gets a chance to run.

    class ProcessOrder implements ShouldQueue
    {
        use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
        public $tries = 5;
        public $backoff = [10, 30, 60];
    
        public function __construct(
            public Order $order  // Serialized as just the ID
        ) {}
    
        public function handle()
        {
            // This never executes if the Order was deleted.
            // $tries and $backoff are completely bypassed.
        }
    }

    Why It Happens

    The model restoration happens in the RestoreModel class, which uses firstOrFail(). This is called by PHP’s __wakeup() / unserialize() pipeline — way before Laravel’s retry middleware kicks in. The job fails immediately with zero retries.

    The Fix: A Nullable Models Trait

    Create a custom trait that returns null instead of throwing when a model is missing:

    trait SerializesNullableModels
    {
        use SerializesModels {
            SerializesModels::__serialize as parentSerialize;
            SerializesModels::__unserialize as parentUnserialize;
        }
    
        public function __unserialize(array $values): void
        {
            try {
                $this->parentUnserialize($values);
            } catch (ModelNotFoundException $e) {
                // Set the property to null instead of exploding
                // Your handle() method can then check for null
            }
        }
    }

    Then in your handle() method:

    public function handle()
    {
        if ($this->order === null) {
            // Model was deleted — release for retry or handle gracefully
            $this->release(30);
            return;
        }
    
        // Normal processing...
    }

    The Takeaway

    SerializesModels is convenient, but it creates a blind spot in your retry logic. If there’s any chance your model might be deleted between dispatch and execution — webhook jobs, async processing after user actions, anything with eventual consistency — either use the nullable trait pattern or pass the ID manually and look it up yourself in handle().

    Your $tries config only works when the exception happens inside handle(). Everything before that is a different world.

  • When Queued Event Listeners Silently Die: The ShouldQueue Trap

    You dispatch an event from inside a queued job. The event has a listener that implements ShouldQueue. Your job completes successfully, but the listener never executes. No exception. No failed job entry. No log. It just… doesn’t run.

    This is one of Laravel’s most frustrating silent failures.

    The Setup

    You have a workflow: when a user account is deactivated, trigger a data export automatically. The architecture looks clean:

    // In your DeactivationHandler (queued job)
    class HandleAccountDeactivation implements ShouldQueue
    {
        public function handle(): void
        {
            // Revoke access tokens for the account
            $this->revokeAccessTokens($this->account);
    
            // Dispatch event for downstream processing
            event(new AccountDeactivated($this->account));
        }
    }
    
    // The listener
    class TriggerDataExport implements ShouldQueue
    {
        public function handle(AccountDeactivated $event): void
        {
            // This never runs!
            $this->exportService->generate($event->account);
        }
    }

    Why It Fails Silently

    When you dispatch an event from within a queued job, and the listener also implements ShouldQueue, the listener gets pushed onto the queue as a new job. But here’s the catch: if the dispatching job’s database transaction hasn’t committed yet (or if the queue connection has issues during nested dispatching), the listener job can fail before it even starts — and this failure happens at the queue infrastructure level, not in your application code.

    A try-catch around event() won’t help. The event dispatch itself succeeds — it pushes a message onto the queue. The failure happens later, when the queue worker tries to process the listener job.

    The Fix: Make Critical Listeners Synchronous

    For listeners that are part of a critical workflow — where silent failure is unacceptable — remove ShouldQueue:

    // Make it synchronous — runs in the same process as the dispatcher
    class TriggerDataExport // No ShouldQueue
    {
        public function handle(AccountDeactivated $event): void
        {
            try {
                $this->exportService->generate($event->account);
            } catch (\Throwable $e) {
                // Now you CAN catch failures
                $event->account->addNote(
                    "Automatic data export failed: {$e->getMessage()}"
                );
                $event->account->flagForReview('compliance');
            }
        }
    }

    Alternative: Direct Method Calls for Critical Paths

    If the listener exists solely because of one dispatcher, skip events entirely for the critical path:

    class HandleAccountDeactivation implements ShouldQueue
    {
        public function handle(DataExportService $exportService): void
        {
            $this->revokeAccessTokens($this->account);
    
            // Direct call instead of event dispatch
            try {
                $exportService->generateComplianceExport($this->account);
            } catch (\Throwable $e) {
                $this->account->addNote("Automatic data export failed: {$e->getMessage()}");
                $this->account->flagForReview('compliance');
            }
        }
    }

    When Events Are Still Right

    Events shine when:

    • Multiple independent listeners react to the same event
    • The listener’s failure doesn’t affect the main workflow
    • You genuinely need decoupling (different bounded contexts)

    But when a queued job dispatches an event to a queued listener for a single critical operation? That’s a fragile chain with a silent failure mode. Make it synchronous or call the service directly.

    The rule of thumb: if the listener failing means the workflow is broken, don’t put a queue boundary between them.