Laravel queue discipline
Skill shaxzodbek-uzb/laravel-guardrails/skills/laravel-queue-discipline
π‘οΈ Production guardrail skills for AI-assisted Laravel β stop cross-tenant leaks, N+1, destructive migrations before your AI agent ships them. For Claude Code, Cursor & 40+ agents.
npx -y skills add shaxzodbek-uzb/laravel-guardrails --skill laravel-queue-disciplineAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
This skill should be used when the agent writes or edits a queued job, mailable, notification, queued listener, or batch/chain β anything implementing ShouldQueue or dispatched via dispatch()/->dispatch()/Bus::batch()/Bus::chain(). Load it when it touches handle(), the job constructor/payload, $tries, $backoff, $timeout, $maxExceptions, $deleteWhenMissingModels, retryUntil(), failed(), ShouldBeUnique/uniqueId, WithoutOverlapping or RateLimited middleware, ->afterCommit(), or dispatches a job inside a DB::transaction; and when the user mentions queues, jobs, workers, Horizon, retries, idempotency, failed_jobs, duplicate jobs, race conditions, or "job ran before the row existed". Loads guardrails for idempotent, bounded, transaction-safe background jobs.
The file declares its own license as MIT. That is the authorβs claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
10.6 KB, as published. Nobody here has run it
Queue & Job Discipline
Queued jobs run at-least-once, out of process, and possibly more than once. Code that assumes "exactly once, right now, with fresh data" corrupts data, double-charges customers, storms retries, or runs before the row it needs exists. This skill keeps jobs idempotent, bounded, and transaction-safe.
The footgun
A job looks like a normal method, so it gets written like one β and four assumptions silently break in production:
- It only runs once. Workers crash, time out, and retry; a delivery can fire twice. A non-idempotent
charge()double-charges. At-least-once delivery is the contract, so the job must be safe to run twice. - The row exists when it runs. Dispatch a job from inside a
DB::transactionand the worker can pick it up before the transaction commits β the model isn't in the database yet, and the job 500s or acts on stale data. A top real-world bug. - Retries are free. No
$tries/$backoffcap means a permanently failing job retries forever, hammering a downstream API and filling the queue β a retry storm. - The payload is just arguments. Stuffing a huge collection or file into the constructor bloats the serialized payload in Redis/DB and slows every worker. Models are special-cased (only the key is serialized) but everything else is stored verbatim.
Rules
- Make every job idempotent. Running
handle()twice must not double-charge, double-send, or duplicate rows. Guard with a status check, a unique constraint +firstOrCreate/updateOrCreate, or an idempotency key. Assume it will run twice. - Dispatch after the transaction commits. When dispatching inside a
DB::transaction, useSomeJob::dispatch(...)->afterCommit(), or set'after_commit' => trueon the queue connection inconfig/queue.php. Otherwise the worker may run before the row exists. - Bound retries explicitly. Set
public int $tries(e.g. 3) orpublic function retryUntil(): \DateTimeInterface. Addpublic $backoff = [10, 60, 300];for incremental backoff, andpublic int $maxExceptionsto stop after N errors even within$tries. Never leave retries unbounded. - Set a timeout.
public int $timeout = 120;kills a hung job (requires thepcntlextension). Keep$timeoutshorter than the worker's--timeout/retry_afterso a job can't be retried while still running. - Prevent duplicate/overlapping runs. Implement
ShouldBeUniquewithuniqueId()andpublic int $uniqueForto dedupe identical dispatches; use theWithoutOverlappingmiddleware to serialize jobs sharing a key (e.g. per-account). UseShouldBeUniqueUntilProcessingif a new dispatch should be allowed once processing starts. - Rate-limit external calls with the
RateLimited/ThrottlesExceptionsmiddleware (returned from amiddleware()method) instead of hammering a third-party API on every retry. - Always handle failure. Implement
failed(\Throwable $e): voidto clean up, mark state, and alert. Monitor thefailed_jobstable and alert on growth; use Horizon (Redis) for visibility, metrics, and balancing. - Keep payloads small β pass IDs, not blobs. A job using the
SerializesModelstrait (bundled into theQueueabletrait thatmake:jobgenerates) serializes only a model's key and re-fetches it on run (fresh data β good). But a deleted model then throwsModelNotFoundException: setpublic bool $deleteWhenMissingModels = true;to discard the job instead. Never pass large arrays, file contents, or big collections into the constructor β pass an id/path and load insidehandle(). - Don't write one multi-hour job. Chunk the work (
chunkById) or use batching βBus::batch([...])->then()->catch()->finally()->dispatch()β and chaining βBus::chain([...])->dispatch()β for sequential steps. Batches give progress and partial-failure handling; chains stop on first failure. - Route by latency. Put slow/bulk jobs on a separate queue/connection from latency-sensitive ones, and run dedicated workers, so a backlog of exports doesn't delay password-reset emails.
- Avoid unserializable payloads. No closures, no resources, no PDO/connection objects in the constructor.
- Test the job, don't just dispatch it.
Queue::fake()/Bus::fake()assert it was queued; also callhandle()directly (ordispatchSync) to assert it does the right thing β and is idempotent when run twice.
Good vs bad
Idempotency + transaction-safe dispatch
// β dispatched mid-transaction (job may run before commit) and not idempotent
DB::transaction(function () use ($data) {
$order = Order::create($data);
ChargeCustomer::dispatch($order); // worker may pick this up before commit
});
class ChargeCustomer implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function handle(PaymentGateway $gw): void
{
$gw->charge($this->order->total); // retry β charges AGAIN
}
}
// β
dispatch after commit + idempotent charge keyed by the order
DB::transaction(function () use ($data) {
$order = Order::create($data);
ChargeCustomer::dispatch($order->id)->afterCommit();
});
class ChargeCustomer implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public int $timeout = 30;
public function __construct(public int $orderId) {} // pass the id
public function handle(PaymentGateway $gw): void
{
$order = Order::findOrFail($this->orderId);
if ($order->charged_at !== null) {
return; // already charged on a prior attempt β idempotent no-op
}
$gw->charge($order->total, idempotencyKey: "order-{$order->id}");
$order->update(['charged_at' => now()]);
}
public function failed(\Throwable $e): void
{
// alert / mark the order so a human can act
}
}
Dedupe overlapping dispatches
// β
only one sync-per-account in flight; identical dispatches deduped for 1h
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Middleware\WithoutOverlapping;
class SyncAccount implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 3600;
public function __construct(public int $accountId) {}
public function uniqueId(): string
{
return (string) $this->accountId;
}
public function middleware(): array
{
return [(new WithoutOverlapping($this->accountId))->releaseAfter(60)];
}
public function handle(): void { /* ... */ }
}
Batching instead of one huge job
// β
thousands of rows as a monitored batch, not a single multi-hour job
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$jobs = User::query()
->where('digest', true)
->pluck('id')
->map(fn ($id) => new SendDigest($id));
Bus::batch($jobs)
->name('daily-digest')
->allowFailures()
->then(fn (Batch $b) => logger("digest done: {$b->processedJobs()} jobs"))
->catch(fn (Batch $b, \Throwable $e) => report($e))
->dispatch();
How to verify
# 1. Queued jobs should set reliability knobs β find jobs missing $tries/$timeout
grep -rL "tries\|retryUntil" app/Jobs
# 2. Dispatches inside a transaction must use afterCommit (or after_commit config)
grep -rn "DB::transaction" app/ -A 15 | grep -i "dispatch(" | grep -v "afterCommit"
# 3. Critical jobs should implement failed()
grep -rL "function failed" app/Jobs
# 4. Fat payloads β constructors taking models/collections instead of ids
grep -rnE "__construct\(.*(Collection|array \\\$).*\)" app/Jobs
# 5. Run the suite (with Queue::fake()/Bus::fake() assertions)
./vendor/bin/pest # or: php artisan test
Assert queueing and idempotency in tests:
use Illuminate\Support\Facades\{Bus, Queue};
it('queues the charge after the order commits', function () {
Queue::fake();
$this->postJson('/orders', [/* ... */])->assertCreated();
Queue::assertPushed(ChargeCustomer::class);
});
it('is idempotent β running twice charges once', function () {
$order = Order::factory()->create(['charged_at' => null]);
$gw = Mockery::spy(PaymentGateway::class);
(new ChargeCustomer($order->id))->handle($gw);
(new ChargeCustomer($order->id))->handle($gw); // second attempt
$gw->shouldHaveReceived('charge')->once();
});
In production, alert when failed_jobs grows and watch Horizon's wait-time/throughput; a climbing failed count or wait time means a job is violating one of the rules above.
When it's OK to bend the rule
dispatchSync()/Bus::dispatchSync(run inline, no queue) is fine for tiny work or inside an already-async context β then transaction/afterCommittiming isn't a concern, but idempotency still is if it can be retried upstream.$tries = 1is correct for jobs that must not retry (e.g. a non-idempotent legacy call you can't make safe) β pair it with strong alerting onfailed().- Passing a whole model (not just an id) is acceptable for small models when you specifically want
SerializesModelsto re-fetch fresh data on run β just set$deleteWhenMissingModelsand keep the model small.
References
- Queues β retries, timeout, backoff,
failed(): https://laravel.com/docs/queues - Unique jobs (
ShouldBeUnique): https://laravel.com/docs/queues#unique-jobs - Job middleware (
WithoutOverlapping,RateLimited): https://laravel.com/docs/queues#job-middleware - Dispatching after database transactions (
afterCommit): https://laravel.com/docs/queues#dispatching-after-database-transactions-commit - Job batching (
Bus::batch): https://laravel.com/docs/queues#job-batching - Job chaining (
Bus::chain): https://laravel.com/docs/queues#job-chaining - Laravel Horizon: https://laravel.com/docs/horizon
- Faking the queue in tests: https://laravel.com/docs/mocking#queue-fake