>Dudenkoff_
Lab · Laravel

There is no magic

Framework claims, tested until they confess: real output, real source, no hand-waving.

July 3, 2026 · The mail room · part 1 of 9

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.

July 4, 2026 · The mail room · part 2 of 9

The model that never rode the queue

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.

July 5, 2026 · The mail room · part 3 of 9

Beyond the minimal job

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.

July 6, 2026 · The mail room · part 4 of 9

Where jobs go when they die

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.

July 7, 2026 · The mail room · part 5 of 9

Queue the listener

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.

July 8, 2026 · The mail room · part 6 of 9

The limits of patience

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.

July 9, 2026 · The mail room · part 7 of 9

The door before the job

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.

July 10, 2026 · The mail room · part 8 of 9

What a group costs

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.

July 11, 2026 · The mail room · part 9 of 9

Workers in production

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.

July 18, 2026 · Open the box · part 1 of 8

One name, two things

$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.

July 19, 2026 · Open the box · part 2 of 8

The list before the question

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.

July 20, 2026 · Open the box · part 3 of 8

Who picks up the phone

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.

July 21, 2026 · Open the box · part 4 of 8

The ten that vanished

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.

July 22, 2026 · Open the box · part 5 of 8

The copy you never asked for

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.

July 23, 2026 · Open the box · part 6 of 8

The date that is not a cast

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.

July 24, 2026 · Open the box · part 7 of 8

The id you never asked for

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.

July 25, 2026 · Open the box · part 8 of 8

The row that would not stay dead

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.