Great, I now have a clear picture of the pattern across your Jobs. Here's my analysis.
Your understanding is accurate. Looking across the Jobs in your project, they all follow the same template:
- Accept primitive IDs (not Eloquent models) as constructor arguments — serializable for the queue.
- Guard — re-fetch the entity from DB; if it's gone, log and bail out early.
- Delegate — call the Port/Service, passing the same (or nearly the same) arguments.
- Retry policy —
$tries,$backoff, or manualrepeatOnFail().
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 toTaboolaS2SVisitBundlePagePort::call($leadInfo)JobTaboolaS2SMakePurchase→ delegates toTaboolaS2SMakePurchasePort::call($order, $revenue)JobCreatePostback→ delegates toCreatePostbackPort::call($order, ...)JobZohoCreateDeal→ delegates toCreateZohoDealPort::create($leadInfo)JobEmailOnLeadPaid→ delegates toSendEmailOnLeadPaidService::call($zohoDeal)JobAddBMobileStat→ delegates toAddBMobileStat::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.
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.
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.
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.
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.
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.
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.
| 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 👽.