Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Job Models Laravel Package

repat/laravel-job-models

Two Eloquent models for Laravel's jobs and job_fails tables, with Carbon casting for timestamps, array casting for payloads, and getters for payload keys like display_name, max_tries, command, and more.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites (non-negotiable). This package is a thin Eloquent layer over two database tables. If your queue driver is redis, sqs, beanstalkd, or sync, there is nothing to model and the package is dead weight. Confirm:

php artisan queue:table          # creates `jobs`
php artisan queue:failed-table   # creates `failed_jobs`
php artisan migrate
grep QUEUE_CONNECTION .env       # must resolve to a database connection

Install. There is no service provider to publish, no config file, no artisan command.

composer require repat/laravel-job-models

Find the class names first. The README only documents \Repat\LaravelJobs\Job. Before writing any application code, open vendor/repat/laravel-job-models/src/ and read both models — there are only two files, and it takes two minutes. That read will answer the three questions the README doesn't: the failed-job class name, the $fillable/$guarded state, and exactly which table each model resolves (version 0.4 added "table name from config," so it should pick up queue.connections.database.table and failed.table rather than hardcoding).

First use case: a "what is stuck right now?" tinker session. This is the single highest-value thing the package gives you — replacing DB::table('jobs')->... with readable objects, immediately.

php artisan tinker
use Repat\LaravelJobs\Job;

// Everything currently waiting, oldest first
Job::orderBy('available_at')->get(['id','queue','attempts','available_at']);

// Reserved but not yet deleted = in-flight or orphaned by a worker
Job::whereNotNull('reserved_at')->get();

// What job is this, in plain English?
$job = Job::first();
$job->display_name;   // App\Jobs\SendInvoiceEmail
$job->max_tries;
$job->command_name;   // ->payload['data']['commandName']
$job->available_at;   // Carbon instance, not a unix int
$job->payload;        // array

If that returns sensible values, you're done with setup. Everything else in this assessment is about turning those reads into dashboards, alerts, and retry tooling.


Implementation Patterns

1. Replace raw queries in existing tooling, one call site at a time

The migration is mechanical and low-risk because the models are read-oriented. Swap DB::table('jobs') for the model and delete your manual json_decode/Carbon::createFromTimestamp boilerplate:

// Before
DB::table('jobs')->where('queue','default')->whereNull('reserved_at')->count();

// After
Job::where('queue','default')->whereNull('reserved_at')->count();

Do this only where you were already reading the tables. Do not introduce the models into the write path — see Gotchas.

2. Extend rather than modify — the package has no model-swap hook

There is no config key to register your own model, so subclassing is the only extension point. This is the right place for the scopes you'll write on day one, because the base model ships none:

namespace App\Models;

use Repat\LaravelJobs\Job as BaseJob;
use Illuminate\Database\Eloquent\Builder;

class Job extends BaseJob
{
    public function scopePending(Builder $q): Builder
    {
        return $q->whereNull('reserved_at');
    }

    public function scopeStuck(Builder $q, int $minutes = 30): Builder
    {
        return $q->whereNotNull('reserved_at')
                 ->where('reserved_at', '<', now()->subMinutes($minutes)->timestamp);
    }

    public function scopeOnQueue(Builder $q, string $queue): Builder
    {
        return $q->where('queue', $queue);
    }
}

Use App\Models\Job in your code, and keep the vendor class imported only in that one file. If you later fork the package or Laravel changes the schema, you patch one class.

3. Health-check command for alerting

The canonical day-to-day win: a scheduled command that pages you on queue degradation instead of a customer telling you.

// app/Console/Commands/QueueHealthCheck.php
$stuck = Job::stuck(30)->count();
$backlog = Job::pending()->count();
$failing = FailedJob::where('failed_at', '>', now()->subMinutes(15))->count();

if ($stuck > 0 || $backlog > 500 || $failing > 10) {
    Notification::route('slack', config('services.slack.webhook'))
        ->notify(new QueueDegraded($stuck, $backlog, $failing));
}

Schedule it every five minutes. Because failed_at is Carbon-cast, the "recent failures" window is a one-liner rather than a string-concatenated SQL date.

4. Failure triage: group failures by job class

command_name is the killer getter for this. Failures arrive as a wall of rows; grouping turns them into a ranked list.

FailedJob::where('failed_at', '>', now()->subDay())
    ->get()
    ->groupBy('command_name')
    ->map->count()
    ->sortDesc();

command_name comes from payload['data']['commandName'], so it works for standard dispatched jobs. Verify it against your real failure rows before you build a dashboard on it — batched, chained, and closure-based jobs do not follow that payload shape (see Gotchas).

5. A job-status endpoint or admin panel

For an internal /jobs/{id} resource, the package removes all casting boilerplate. Keep the payload out of the list view — it's large and you rarely need it:

Route::get('/jobs', fn () => Job::query()
    ->select(['id','queue','attempts','available_at','reserved_at'])
    ->latest('id')
    ->paginate(50));

Route::get('/jobs/{job}', fn (Job $job) => [
    'id'           => $job->id,
    'queue'        => $job->queue,
    'attempts'     => $job->attempts,
    'display_name' => $job->display_name,
    'available_at' => $job->available_at,
    'reserved_at'  => $job->reserved_at,
    'payload'      => $job->payload,   // already an array, JSON-encodes directly
]);

Returning $job->payload directly into a JSON response is one of the nicer properties of the array cast — no json_decode at the serialization boundary.

6. Retry flows: read here, act with Artisan

The models give you reads, not retries. For retrying failed jobs, use the failed-job UUID with Laravel's own command so you inherit its semantics (attempt reset, failed_at deletion):

Artisan::call('queue:retry', ['id' => [$failedJob->uuid]]);

Same for queue:forget. Wrapping the Artisan calls keeps you on Laravel's supported code path and avoids hand-deleting rows the framework expects to manage. If you need the queue name or reason before retrying, pull it off the model for the confirm dialog — that's what display_name is for on an admin UI aimed at support staff.

7. Audit/export of payload data

For compliance exports or post-incident forensics, payload as an array means you can ->map() and stream it:

Job::query()->select(['id','payload'])->lazyById(500)->each(function ($job) use ($out) {
    $out->put([$job->id, json_encode($job->payload)]);
});

lazyById matters here — the payload column is fat and ->get() on a large backlog will hurt.


Gotchas and Tips

The README is wrong about the table name. It says job_fails; queue:failed-table creates failed_jobs. The package resolves names from config('queue.connections.database.table') and config('queue.connections.database.failed.table') (release 0.4), so it works — but don't grep your database for job_fails and conclude the install failed.

Never write through these models in production. The jobs table is live machinery: workers depend on atomic updates to reserved_at and attempts, and Laravel's DatabaseQueue pop/delete logic assumes it's the only writer. Reading is safe. Job::create(), ->update(), ->delete() from application code will produce ghost jobs, double-execution, or invisible retries. If the models ship a permissive $guarded/$fillable, consider wrapping your subclass with an explicit block on writes, or at minimum leave a comment at the top of the class. The same caution applies inversely to factories — release 0.7 fixed the constructor for factory use, so Job::factory() will happily create rows; use it in tests against a test schema only.

command unserializes database content. The command getter runs unserialize() over the payload. Two consequences. First, if the job class has been renamed, moved, or removed since the row was written, you get __PHP_Incomplete_Class and a confusing object rather than an error. Second, if the class defines __wakeup/__unserialize with side effects, you've just triggered them inside your web request. Prefer command_name (a plain string) for anything in a UI or a loop; reach for command only interactively in tinker when you actually need the configured properties.

Payload getters return null more often than you'd expect. command_name, max_tries, delay, timeout, and timeout_at are all reads into the payload. Batched jobs, chained jobs, Bus::chain, and closures in payloads use different payload shapes, and timeout_at is only present on the queue row when the driver wrote it. Always null-guard:

$job->timeout_at?->diffForHumans() ?? 'no timeout set';

Timestamps are casts, not columns. reserved_at, available_at, and created_at on jobs are integer UNIX timestamps; the package's casts turn them into Carbon. That means comparisons you write against the model must use timestamps, not Carbon objects, unless Eloquent translates them for you:

Job::where('reserved_at', '<', now()->subMinutes(30)->timestamp)  // correct
Job::where('reserved_at', '<', now()->subMinutes(30))             // may not bind as you expect

Prefer ->timestamp explicitly. It costs nothing and removes an entire class of silent "no rows returned" bugs.

payload as an array cast is lossy for JSON queries. Once cast, you can't whereJsonContains against the underlying column through the model in the way you'd expect from a json column — the jobs table stores payload as longText/text on most drivers. For payload-level analytics, either drop to DB::table() with a driver-specific expression, or select('payload') and filter in PHP over a bounded set.

Performance: this is a hot, high-churn table. jobs is written and deleted constantly, so it has no useful history and no indexes beyond what Laravel's migration creates (the queue index, roughly). Avoid:

  • ->count() on the whole table in a per-request middleware,
  • ->get() without a limit or select() (payloads are large),
  • anything resembling a dashboard that polls every few seconds.

Bound everything by queue name or a time window, and always select() away the payload column in list views. For sustained analytics, don't query jobs at all — denormalize events via JobProcessing/JobProcessed listeners instead.

failed_jobs is your durable history; jobs is not. Jobs are deleted on success, so the jobs table only tells you about pending work. Any "what happened yesterday" question has to be answered from failed_jobs, which Laravel never prunes automatically. If you build reporting on FailedJob, add a retention job of your own, or the table grows unbounded and your grouped-by-command_name query starts scanning years of rows.

No observers, no events, no Horizon integration. If you need lifecycle hooks, they come from Laravel's queue events (Queue::failing, JobFailed), not this package. If you're already running Horizon, you have a superset of this UI — the package's value drops to "typed access in code," and you may prefer a handful of scopes on DB::table().

Vendor risk is real: 8 stars, 0 dependents, one maintainer. Pin the version in composer.json and read the diff before bumping. Mitigating this is cheap because the surface area is two models with no business logic — if maintenance stops, vendor the two files into app/Models/ in an afternoon. The release history (0.9 → Laravel 11, 0.10 → Laravel 12) shows the maintainer does track majors, so the risk is lag rather than abandonment. Add a single integration test that asserts Job::first() and FailedJob::first() work against your schema; it will fail loudly on the upgrade that breaks you.

Custom table names are handled — custom columns are not. If you've extended the queue schema, the models' $casts and getters will simply not know about your column, and $job->my_column returns null. That's the signal to stop extending the package and write your own model.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel