Checklist/Docs/Laravel-style queues → FastAPI (RabbitMQ / Kafka)

Guide 13

Laravel-style queues → FastAPI (RabbitMQ / Kafka)

Audience: Teams who know Laravel queues and are building the same ideas with FastAPI. Policy reminder: Durable jobs use RabbitMQ (commands) and/or Kafka (events). Redis is not the primary job broker in this checklist.

TL;DR

Laravel gives you one opinionated queue API (ShouldQueue, dispatch, middleware, batches, Horizon). In FastAPI you assemble the same behaviors from:

Laravel ideaFastAPI-side approach
ShouldQueue job classTask/actor function + job row in your DB
dispatch()Publish to RabbitMQ (Celery / Taskiq / Dramatiq)
queue:workSeparate worker process/container
Connections vs queuesBroker URL vs queue/topic names
Job middlewareTask decorators / custom wrappers / middleware
Unique / without overlappingIdempotency key + cache/DB lock
after_commitTransactional outbox (preferred)
Failed jobs tablejobs status=failed + DLQ
HorizonFlower / RMQ management / Kafka UI + your metrics
Batches / chainsOrchestrator job, saga, or workflow table

Laravel is a framework product. FastAPI is a web library : you own the job domain model.

Contents

  1. Mental model
  2. Connections vs queues
  3. Creating jobs
  4. Dispatching
  5. Middleware equivalents
  6. Retries, timeouts, failed jobs
  7. Workers and deploy
  8. Batches, chains, unique, debounce
  9. Testing
  10. Feature map (Laravel → stack)
  11. What not to copy blindly

---

1. Mental model

Laravel

text
Controller → ProcessPodcast:dispatch($model)
Worker (queue:work) → handle()
failed_jobs / Horizon

FastAPI (this checklist)

text
Route → insert jobs row (pending) → publish command (RabbitMQ)
Worker process → load job by id → run → succeeded|failed
Client → GET /jobs/{id} (product status from DB, not broker UI)
LaravelYou implement
Framework serializes job classJSON message { "job_id", "type" } only
Eloquent model on queuePass IDs; reload in worker
failed_jobs migrationYour jobs table + optional DLQ
Queue:fake()Fake bus / mock publish in tests

See 03 Job lifecycle.

---

2. Connections vs queues

Laravel:

  • Connection = backend (redis, sqs, database, …) in config/queue.php
  • Queue name = stack on that connection (emails, high, …)

FastAPI / Celery-style:

ConceptExample
Connection / brokeramqps://…@rabbitmq// (Pydantic Settings)
Queue namejobs.default, jobs.high, jobs.heavy, jobs.io
Kafka “connection”Bootstrap servers + topic billing.events
python
# PSEUDOCODE : settings
class Settings(BaseSettings):
 rabbitmq_url: str
 default_job_queue: str = "jobs.default"
python
# PSEUDOCODE : route to named queue (Celery-like)
send_receipt.apply_async(args=[job_id], queue="jobs.io")
# Taskiq
await send_receipt.kicker().with_queue("jobs.io").kiq(job_id)

Worker priority (Laravel --queue=high,default):

bash
# Celery: separate workers or -Q high,default
celery -A app worker -Q jobs.high,jobs.default

---

3. Creating jobs

Laravel job class

php
class ProcessPodcast implements ShouldQueue {
 public function handle(AudioProcessor $processor): void { ... }
}

FastAPI equivalent shape

python
# PSEUDOCODE : prefer IDs on the bus
@celery_app.task(name="podcasts.process")
def process_podcast(job_id: str) -> None:
 job = load_job(job_id)
 if job.status == "succeeded":
 return
 podcast = load_podcast(job.entity_id) # reload; don't trust stale snapshot
 AudioProcessor().run(podcast)
 mark_succeeded(job_id)
Laravel habitFastAPI habit
Pass Eloquent model into jobPass podcast_id / job_id
Container injects into handleExplicit deps or DI in Taskiq
Huge serialized relationsForbidden : small JSON only
Binary on queueObject storage + ref

---

4. Dispatching

LaravelFastAPI pattern
ProcessPodcast:dispatch($p)Create job row + task.delay(job_id) / .kiq / .send
dispatch()->delay(...)ETA/countdown (Celery), schedule, or delayed exchange
dispatch_syncCall use-case function in-process (tests/admin only)
Bus:batch([...])Batch table + child jobs (you build it)
Bus:chain([...])Chain in message / next_step field / saga
after_commit()Outbox in same DB transaction (04)

after_commit ≈ outbox (important)

Laravel after_commit avoids dispatching if the HTTP transaction rolls back. In FastAPI, do not rely on “publish after await commit” alone under load : use:

python
# PSEUDOCODE
async with db.transaction():
 job = await insert_job(...)
 await insert_outbox(queue="jobs.default", payload={"job_id": job.id})
# relay publishes after commit

202 response (Laravel often still returns 200 after dispatch)

python
# PSEUDOCODE
@router.post("/podcasts/{id}/process", status_code=202)
async def process(id: str, db: Db, bus: Bus):
 job = await enqueue(db, bus, type="podcast.process", entity_id=id)
 return {"job_id": job.id, "status": "pending"}

---

5. Middleware equivalents

Laravel job middleware (rate limit, without overlapping, throttle exceptions) map to:

Laravel middlewareFastAPI approach
RateLimitedRedis token bucket in task wrapper; per-queue worker concurrency; broker prefetch
WithoutOverlappingRedis/DB lock keyed by user_id / order_id before work
ThrottlesExceptionsError taxonomy + backoff (09)
release($seconds)Retry with countdown / nack + delay plugin
Skip jobEarly return if job.status == succeeded
python
# PSEUDOCODE : WithoutOverlapping-style
async def handle(job_id: str, entity_id: str):
 lock = await redis.lock(f"job:podcast:{entity_id}", ttl=300)
 if not await lock.acquire(blocking=False):
 raise Retry(countdown=30) # or release equivalent
 try:
 await do_work(job_id)
 finally:
 await lock.release()

Unique jobs (ShouldBeUnique):

python
# PSEUDOCODE : unique by product
# 1) UNIQUE(idempotency_key) on jobs table, or
# 2) cache lock before publish
key = f"unique:reindex:{product_id}"
if not await cache.set_nx(key, "1", ttl=3600):
 return existing_job

Debounced jobs: store “latest wins” token in Redis; worker checks token still current before running (or use a short delay + version number).

Encrypted jobs: encrypt payload fields yourself or put secrets in vault and pass IDs only (preferred).

---

6. Retries, timeouts, failed jobs

LaravelFastAPI / Celery-like
--tries=3max_retries / task config
--backoff / backoff()Exponential backoff + jitter
--timeoutSoft/hard time limits; HTTP client timeouts
retry_after (visibility)RabbitMQ consumer timeout / ack rules; don’t set visibility < runtime
failed_jobs tablejobs.status=failed + last_error
queue:retryAdmin redrive from DLQ or status=pending + re-publish
failed() methodmark_failed + alert hook
DeleteWhenMissingModelsCatch not-found → mark cancelled/failed without retry
python
# PSEUDOCODE : failed hook
except PermanentError as e:
 await mark_failed(job_id, str(e))
 await notify_ops(job_id, e)

DLQ: RabbitMQ dead-letter exchange → jobs.dlq (see 09).

---

7. Workers and deploy

LaravelFastAPI stack
php artisan queue:workcelery worker / taskiq worker / dramatiq
Supervisor numprocsK8s replicas / Compose worker service scale
queue:restartRolling restart; graceful SIGTERM; finish in-flight
Maintenance mode skips jobsYour feature flag or stop workers
Horizon (Redis)Flower (Celery) + RMQ UI; not user-facing status
--queue=high,default-Q jobs.high,jobs.default or dedicated deployments
text
# compose
api: uvicorn
worker: celery/taskiq/dramatiq
rabbitmq:
db:
# optional flower (private network + auth)

Graceful shutdown: termination grace > longest job (same idea as Supervisor stopwaitsecs).

---

8. Batches, chains, unique, debounce

Laravel has first-class batches and chains. In FastAPI you model them explicitly:

Chain

python
# PSEUDOCODE : next step on success
async def step_a(job_id):
 await do_a()
 await enqueue_child(type="step_b", parent_id=job_id)

Or a single orchestrator message with steps: ["a","b","c"] and cursor.

Batch

sql
-- PSEUDOCODE
batch(id, status, total, done, failed)
batch_jobs(batch_id, job_id)

When done + failed == total, mark batch complete / run then callback job.

SQS FIFO / fair queues

On RabbitMQ: separate queues + careful prefetch; per-tenant queues if needed. On Kafka: key by tenant for ordering. True SQS FIFO is an AWS concept : use RMQ/Kafka patterns instead unless you actually run SQS from Python.

---

9. Testing

LaravelFastAPI
Queue:fake()FakeBus dependency override
Queue:assertPushedAssert fake_bus.messages
Bus:assertChainedAssert ordered publishes / child rows
Run handle in unit testCall task function with fakes
python
# PSEUDOCODE
def test_dispatch_export(client, fake_bus):
 r = client.post("/exports", json={...})
 assert r.status_code == 202
 assert fake_bus.messages[0]["queue"] == "jobs.heavy"

Full guide: 10 Testing workers.

---

10. Feature map (Laravel → stack)

Laravel queues doc sectionClosest implementation
Introduction / why queues01 Async vs jobs
Connections vs queuesSettings + queue names (this guide §2)
Creating job classesCelery/Taskiq/Dramatiq tasks + job table
Unique jobsIdempotency key + lock
Debounced jobsRedis version / delay + “latest token”
Encrypted jobsDon’t put secrets on bus; encrypt if you must
Job middlewareWrappers / locks / rate limits
Delayed dispatchETA / delayed message
Sync dispatchIn-process call
Bulk dispatchLoop + bulk insert jobs + multi-publish
Jobs & DB transactionsOutbox 04
Job chainingSaga / next_step / chain messages
Max attempts / timeoutTask config + client timeouts 09
Queue failoverMulti-AZ broker; not “second Redis” as source of truth
Error handlingTaxonomy + DLQ
Job batchingBatch tables you own
Queueing closuresAvoid; named tasks only (debuggable)
queue:workWorker containers
SupervisorK8s/systemd/Compose restart policy
Failed jobsjobs failed + DLQ redrive
Clearing queuesRMQ purge (ops only; dangerous)
MonitoringDepth/lag metrics + alerts
TestingFakes + smoke 10
Job eventsLogging middleware / OpenTelemetry spans

Framework pick (Laravel “one way” → your choice)

If you liked…Prefer
Jobs + Horizon-ish ops + sync PHP styleCelery + RabbitMQ (+ Flower private)
Modern async, DITaskiq + RabbitMQ
Small actors, simple APIDramatiq + RabbitMQ
Events / pub-sub like Laravel events at scaleFastStream + Kafka (+ outbox)

---

11. What not to copy blindly

Laravel convenienceRisk if copied naively
Redis as default queueThis checklist wants RabbitMQ/Kafka for durable business jobs
Eloquent models on the queueHuge payloads, stale relations : use IDs
Closures on the queueOpaque, hard to ops
Horizon as product statusUsers need your job API
dispatch without job rowNo user-visible status / audit
Infinite retriesPoison loops : bound attempts + DLQ

---

Pseudocode: “Laravel dispatch” in one place

python
# PSEUDOCODE : application service used by FastAPI routes
async def dispatch_job(
 *,
 type: str,
 entity_id: str,
 idempotency_key: str | None,
 queue: str,
 db: Db,
 bus: Bus,
) -> Job:
 if idempotency_key:
 existing = await db.find_job_by_key(idempotency_key)
 if existing:
 return existing

 async with db.transaction():
 job = await db.insert_job(
 type=type,
 status="pending",
 entity_id=entity_id,
 idempotency_key=idempotency_key,
 )
 await db.insert_outbox(
 destination=queue,
 payload={"job_id": str(job.id), "type": type},
 )
 return job

That is the portable core of Laravel’s “push a job and forget” : with production-safe dual-write behavior.

See also

External reading

  • Laravel queues (official) : conceptual checklist for features to re-implement
  • Not a runtime dependency of this FastAPI standard

Creator note: Patterns above are for FastAPI production systems; Laravel remains the reference for *feature completeness*, not the deploy target of this site.