Skip to content

Instantly share code, notes, and snippets.

@deploy595
Created February 26, 2026 04:34
Show Gist options
  • Select an option

  • Save deploy595/c94d0551a7c29571855350a429448fe3 to your computer and use it in GitHub Desktop.

Select an option

Save deploy595/c94d0551a7c29571855350a429448fe3 to your computer and use it in GitHub Desktop.
Jobs as Thin Queue Wrappers (Command Envelope Pattern)

Great, I now have a clear picture of the pattern across your Jobs. Here's my analysis.


Yes, you are doing it right

Your understanding is accurate. Looking across the Jobs in your project, they all follow the same template:

  1. Accept primitive IDs (not Eloquent models) as constructor arguments — serializable for the queue.
  2. Guard — re-fetch the entity from DB; if it's gone, log and bail out early.
  3. Delegate — call the Port/Service, passing the same (or nearly the same) arguments.
  4. Retry policy$tries, $backoff, or manual repeatOnFail().

The Job itself contains zero business logic. It's pure infrastructure glue: "take these IDs, hydrate models, call the service, handle retries."

This is visible in every single Job:

  • JobTaboolaS2SVisitBundlePage → delegates to TaboolaS2SVisitBundlePagePort::call($leadInfo)
  • JobTaboolaS2SMakePurchase → delegates to TaboolaS2SMakePurchasePort::call($order, $revenue)
  • JobCreatePostback → delegates to CreatePostbackPort::call($order, ...)
  • JobZohoCreateDeal → delegates to CreateZohoDealPort::create($leadInfo)
  • JobEmailOnLeadPaid → delegates to SendEmailOnLeadPaidService::call($zohoDeal)
  • JobAddBMobileStat → delegates to AddBMobileStat::call(Args(...))

And yes — you can always replace dispatch(...) with a direct service call and get the same result synchronously (e.g. from artisan tinker, a console command, a test, or a controller). The Job is just the async envelope.


What this resembles / what concepts it "smells of"

1. Thin Job / Thin Handler pattern

This is the most direct name. Just like "Thin Controller" in MVC (controller does no logic, delegates to service), you have Thin Jobs — they're pure delegation wrappers around your domain services.

2. Command Bus / Command–Handler separation (CQRS-lite)

Your Jobs behave like Commands in the Command Bus pattern:

  • The Job class = the Command (a serializable message with data).
  • handle() = the Handler that receives the command and dispatches to domain logic.
  • Laravel's queue system = the Bus (transport layer).

In classic CQRS frameworks (Tactician, Ecotone, Symfony Messenger), you'd have a SendTaboolaPostbackCommand DTO and a separate SendTaboolaPostbackHandler. You've essentially collapsed them into one class (the Job), but the separation of concerns is the same: the message carrier knows nothing about the business rules.

3. Ports & Adapters (Hexagonal Architecture)

You're already using this explicitly. The Job depends on a Port (interface like TaboolaS2SVisitBundlePagePort), not a concrete service. The domain layer doesn't know it's being called from a queue — it could be called from HTTP, CLI, a test, or a cron. The Job is an adapter on the "driving" side: it adapts the queue infrastructure to the domain port.

4. Infrastructure vs. Domain separation (Clean Architecture)

In Uncle Bob's terms:

  • Jobs live in the Infrastructure / Frameworks & Drivers layer.
  • Services and Ports live in the Application / Domain layer.

The Job is an infrastructure detail. The domain doesn't depend on it, and that's exactly right.

5. Message / Envelope pattern

The Job is a serializable envelope (message) that carries just enough data (IDs, scalars) to reconstruct the context on the other side. The guard at the start of handle() is essentially the "rehydration" step — converting the message payload back into rich domain objects.

6. Retry as an infrastructure concern

By placing $tries, $backoff, and repeatOnFail() in the Job rather than the Service, you cleanly separate reliability/resilience (infrastructure) from business rules (domain). The service throws an exception on failure; the Job decides whether and when to retry. This is exactly how it should be.


Summary

Concept How it manifests in your code
Thin Job Job has no business logic, just guard + delegate
Command Bus Job = Command + Handler, queue = Bus
Hexagonal / Ports & Adapters Job depends on Port interface, not concrete Service
Clean Architecture Jobs = infrastructure layer; Services = domain layer
Message / Envelope Job carries serializable primitives, rehydrates in handle()
Retry as infrastructure $tries, $backoff live in Job, not Service

You're doing it right. The interchangeability you describe — "dispatch via queue OR call the service directly" — is the hallmark of good architecture. The domain is queue-agnostic, testable in isolation, and the Job is just a thin async delivery mechanism.

Your NEO 👽.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment