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 idea | FastAPI-side approach |
|---|---|
ShouldQueue job class | Task/actor function + job row in your DB |
dispatch() | Publish to RabbitMQ (Celery / Taskiq / Dramatiq) |
queue:work | Separate worker process/container |
| Connections vs queues | Broker URL vs queue/topic names |
| Job middleware | Task decorators / custom wrappers / middleware |
| Unique / without overlapping | Idempotency key + cache/DB lock |
after_commit | Transactional outbox (preferred) |
| Failed jobs table | jobs status=failed + DLQ |
| Horizon | Flower / RMQ management / Kafka UI + your metrics |
| Batches / chains | Orchestrator job, saga, or workflow table |
Laravel is a framework product. FastAPI is a web library : you own the job domain model.
Contents
- Mental model
- Connections vs queues
- Creating jobs
- Dispatching
- Middleware equivalents
- Retries, timeouts, failed jobs
- Workers and deploy
- Batches, chains, unique, debounce
- Testing
- Feature map (Laravel → stack)
- What not to copy blindly
---
1. Mental model
Laravel
Controller → ProcessPodcast:dispatch($model)
Worker (queue:work) → handle()
failed_jobs / HorizonFastAPI (this checklist)
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)| Laravel | You implement |
|---|---|
| Framework serializes job class | JSON message { "job_id", "type" } only |
| Eloquent model on queue | Pass IDs; reload in worker |
failed_jobs migration | Your 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:
| Concept | Example |
|---|---|
| Connection / broker | amqps://…@rabbitmq// (Pydantic Settings) |
| Queue name | jobs.default, jobs.high, jobs.heavy, jobs.io |
| Kafka “connection” | Bootstrap servers + topic billing.events |
# PSEUDOCODE : settings
class Settings(BaseSettings):
rabbitmq_url: str
default_job_queue: str = "jobs.default"# 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):
# Celery: separate workers or -Q high,default
celery -A app worker -Q jobs.high,jobs.default---
3. Creating jobs
Laravel job class
class ProcessPodcast implements ShouldQueue {
public function handle(AudioProcessor $processor): void { ... }
}FastAPI equivalent shape
# 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 habit | FastAPI habit |
|---|---|
| Pass Eloquent model into job | Pass podcast_id / job_id |
Container injects into handle | Explicit deps or DI in Taskiq |
| Huge serialized relations | Forbidden : small JSON only |
| Binary on queue | Object storage + ref |
---
4. Dispatching
| Laravel | FastAPI pattern |
|---|---|
ProcessPodcast:dispatch($p) | Create job row + task.delay(job_id) / .kiq / .send |
dispatch()->delay(...) | ETA/countdown (Celery), schedule, or delayed exchange |
dispatch_sync | Call 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:
# PSEUDOCODE
async with db.transaction():
job = await insert_job(...)
await insert_outbox(queue="jobs.default", payload={"job_id": job.id})
# relay publishes after commit202 response (Laravel often still returns 200 after dispatch)
# 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 middleware | FastAPI approach |
|---|---|
RateLimited | Redis token bucket in task wrapper; per-queue worker concurrency; broker prefetch |
WithoutOverlapping | Redis/DB lock keyed by user_id / order_id before work |
ThrottlesExceptions | Error taxonomy + backoff (09) |
release($seconds) | Retry with countdown / nack + delay plugin |
| Skip job | Early return if job.status == succeeded |
# 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):
# 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_jobDebounced 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
| Laravel | FastAPI / Celery-like |
|---|---|
--tries=3 | max_retries / task config |
--backoff / backoff() | Exponential backoff + jitter |
--timeout | Soft/hard time limits; HTTP client timeouts |
retry_after (visibility) | RabbitMQ consumer timeout / ack rules; don’t set visibility < runtime |
failed_jobs table | jobs.status=failed + last_error |
queue:retry | Admin redrive from DLQ or status=pending + re-publish |
failed() method | mark_failed + alert hook |
DeleteWhenMissingModels | Catch not-found → mark cancelled/failed without retry |
# 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
| Laravel | FastAPI stack |
|---|---|
php artisan queue:work | celery worker / taskiq worker / dramatiq |
Supervisor numprocs | K8s replicas / Compose worker service scale |
queue:restart | Rolling restart; graceful SIGTERM; finish in-flight |
| Maintenance mode skips jobs | Your 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 |
# 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
# 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
-- 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
| Laravel | FastAPI |
|---|---|
Queue:fake() | FakeBus dependency override |
Queue:assertPushed | Assert fake_bus.messages |
Bus:assertChained | Assert ordered publishes / child rows |
Run handle in unit test | Call task function with fakes |
# 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 section | Closest implementation |
|---|---|
| Introduction / why queues | 01 Async vs jobs |
| Connections vs queues | Settings + queue names (this guide §2) |
| Creating job classes | Celery/Taskiq/Dramatiq tasks + job table |
| Unique jobs | Idempotency key + lock |
| Debounced jobs | Redis version / delay + “latest token” |
| Encrypted jobs | Don’t put secrets on bus; encrypt if you must |
| Job middleware | Wrappers / locks / rate limits |
| Delayed dispatch | ETA / delayed message |
| Sync dispatch | In-process call |
| Bulk dispatch | Loop + bulk insert jobs + multi-publish |
| Jobs & DB transactions | Outbox 04 |
| Job chaining | Saga / next_step / chain messages |
| Max attempts / timeout | Task config + client timeouts 09 |
| Queue failover | Multi-AZ broker; not “second Redis” as source of truth |
| Error handling | Taxonomy + DLQ |
| Job batching | Batch tables you own |
| Queueing closures | Avoid; named tasks only (debuggable) |
queue:work | Worker containers |
| Supervisor | K8s/systemd/Compose restart policy |
| Failed jobs | jobs failed + DLQ redrive |
| Clearing queues | RMQ purge (ops only; dangerous) |
| Monitoring | Depth/lag metrics + alerts |
| Testing | Fakes + smoke 10 |
| Job events | Logging middleware / OpenTelemetry spans |
Framework pick (Laravel “one way” → your choice)
| If you liked… | Prefer |
|---|---|
| Jobs + Horizon-ish ops + sync PHP style | Celery + RabbitMQ (+ Flower private) |
| Modern async, DI | Taskiq + RabbitMQ |
| Small actors, simple API | Dramatiq + RabbitMQ |
| Events / pub-sub like Laravel events at scale | FastStream + Kafka (+ outbox) |
---
11. What not to copy blindly
| Laravel convenience | Risk if copied naively |
|---|---|
| Redis as default queue | This checklist wants RabbitMQ/Kafka for durable business jobs |
| Eloquent models on the queue | Huge payloads, stale relations : use IDs |
| Closures on the queue | Opaque, hard to ops |
| Horizon as product status | Users need your job API |
dispatch without job row | No user-visible status / audit |
| Infinite retries | Poison loops : bound attempts + DLQ |
---
Pseudocode: “Laravel dispatch” in one place
# 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 jobThat is the portable core of Laravel’s “push a job and forget” : with production-safe dual-write behavior.
See also
- 01 Async vs jobs
- 02 Brokers
- 03 Job lifecycle
- 04 Outbox / inbox
- 05 Celery · 06 Taskiq · 07 Dramatiq
- 09 Errors / DLQ
- 10 Testing
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.