The minimal job
What a job actually is - built from the empty stub up: dispatch it, catch the empty-queue surprise, open the payload with a SELECT, hire the worker, and measure what the mailbox buys. The chapter the queue docs never wrote.
Framework claims, tested until they confess: real output, real source, no hand-waving.
What a job actually is - built from the empty stub up: dispatch it, catch the empty-queue surprise, open the payload with a SELECT, hire the worker, and measure what the mailbox buys. The chapter the queue docs never wrote.
Pass an Eloquent model to a job and the payload keeps a claim tag - class, id, relation names, connection. The worker trades it for fresh rows: ships 999 when you dispatched 250, throws when the row is gone. Snapshots, DeleteWhenMissingModels, WithoutRelations - every corner verified live.
Five problems from real development, five jobs built for them: a reminder that waits, an order that respects its transaction, an invoice that refuses to exist twice, a card number nobody else can read, a checkout with a fast lane - every switch verified with a SELECT.
A job throws - one attempt, then the morgue: failed_jobs keeps the uuid, the cause of death and the payload intact. queue:retry raises the same job into today's code, #[Tries] and #[Backoff] buy it patience, and failed() speaks the last word. Every step read straight from the tables.
Flip ShouldQueue on a listener and the spinner drops from 2004 ms to 5.3 ms. Then open the payload: CallQueuedListener carries the listener by name, the event by value, the model as a claim tag - and fire() → call() → handle() finishes the route. A queued listener is a job the framework wrote for you.
A job can hang, throw, or give up on its own terms, and Laravel keeps a separate limit for each. #[Timeout] lives on the daemon worker, retryUntil() is a deadline not a count, release() ends in DONE and not the morgue, #[MaxExceptions] and $this->fail() cut it short. Six limits, read straight from the tables.
Two syncs for the same product, two workers, two DONE - and a job still in the queue. Job middleware wraps handle() in a pipeline: WithoutOverlapping is a lock, RateLimited a budget, ThrottlesExceptions a breaker, and each can end a job without ever running it. Read straight from cache_locks and failed_jobs.
Chain three jobs and the queue holds one row - the other two are serialized inside the first, the catch() closure riding with them. A chain carries the group in its payload; a batch keeps it in a job_batches row every worker locks with SELECT ... FOR UPDATE. Two ways to remember a group, two currencies. Read from the payload and the ledger.
Two workers on one table never collide: the reserve query ends in FOR UPDATE SKIP LOCKED, read straight from the Postgres log. Then the fleet: a reservation is a lease whose term lives in the reader, a lease cannot tell dead from slow (one job, two live pids), the timeout alarm kills before the lease frees, and Supervisor refills the slot. Four clocks, byte-exact.
$order->customer and $order->customer() come from one line on the model and behave nothing alike: the method hands back an open query and fires nothing, the property runs it once and caches. Build the naive version where a relation is a plain property and a single find becomes five queries, while the real thing stays one. Laziness is the default, eagerness a lever. Counted straight from the query log.
with('customer') folds an N+1 loop back to two queries and asks for Alice once. The obvious fix, a shared cache, prints the same numbers on three orders and dies at a hundred: 102 queries, because a cache answers one miss at a time. The real move collects every customer_id before the first question to customers exists, asks once with the list, and stitches the answers back through a dictionary. Built by hand, then found in the vendor, verb for verb.
Order::where() runs while method_exists says false, and the last episode left the question of who answers. A shared static builder dies of dirty state, a hand-rolled __callStatic almost passes, and then the vendor chain opens: a fresh instance per static call, __call, and a fresh Eloquent Builder from newQuery. The scars: a BadMethodCallException relayed and rewritten until it names the class you actually called, and query(), the honest door beside the hatch.
Two tinker sessions race the same order: the read-modify-write update loses ten dollars on screen, a classic lost update. Why Eloquent's increment survives the race condition: an atomic SQL formula built in incrementEach, and why the model never forwards the call to the builder. The proof is one byte: a formula without a key updates all 103 orders at once.
Assign 999, ask getDirty, and the model answers without touching the database: every Eloquent model carries a second copy of its row. Dirty tracking from the inside: where $original is born, why save() on a clean model sends zero queries, why a naive touched-flag resave costs 103 pointless UPDATEs, and the strcmp scar that makes "160.0" dirty while "160" is not.
class_basename says Carbon, the copies from the last episode hold a plain string, and getCasts() does not even list created_at. Laravel's cast and accessor machine from the inside: the three doors of transformModelValue, the getDates gift that comes with timestamps, why every access builds a fresh Carbon (no cache, unlike relations), and the trap where ->addDay() on the way out changes nothing.
new Order, exists false, one save: the INSERT ships with returning "id" and the model learns its own name in the same round trip, no second query. How a row is born: performInsert, both timestamps stamped for free, insertGetId and the Postgres RETURNING clause, Builder::create as new-fill-save in one line, and the mass assignment shield that drops unknown keys in silence.
delete() sends one keyed DELETE, flips exists back to false, and leaves a ghost in your hand: the object keeps every attribute after the row is gone, id included. Then the ghost saves itself back, and the INSERT ships the old id as an explicit column, in the exact order the copy holds. Why the model never delegates death, the silent return where increment once hit all 103 rows, and a resurrection that is not a soft delete.