kpconnell/laravel-jobwarden

A database-backed alternative to Horizon & Laravel Queues — durable jobs, sophisticated batches, high observability & scheduling that survive worker and host crashes, with no Redis to operate.

Maintainers

Package info

github.com/kpconnell/laravel-jobwarden

pkg:composer/kpconnell/laravel-jobwarden

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0-beta 2026-07-01 10:15 UTC

This package is auto-updated.

Last update: 2026-07-01 11:45:24 UTC


README

Tests Latest Version License

A durable, database-backed job, batch & scheduling engine for Laravel — a deliberate alternative to the Redis/Horizon model in which the relational database is the source of truth and the coordination layer. Correctness, recovery, and observability come from durable state transitions, per-process fencing tokens, an idempotency-gated retry guard, and verifiable OS-process identity — never from assuming a worker is healthy.

Status: 1.0.0-beta. The distributed-correctness core and the full feature set are complete and proven against SQLite, MariaDB/MySQL, and PostgreSQL (134 tests). APIs may shift slightly before 1.0.0.

Why

  • Survives worker death. A worker (or its whole host) can crash mid-job. A reaper detects the dead process by its stale lease, orphans its in-flight work with a bumped fencing token, and re-runs it elsewhere — and every step is recorded in a durable audit ledger.
  • No Redis to operate. Your database is already durable, transactional, and backed up. JobWarden coordinates entirely through it (FOR UPDATE SKIP LOCKED, with an optimistic-CAS fallback where that isn't available).
  • Idempotency is a first-class, binary decision. Each job declares idempotent(). Lost idempotent jobs retry automatically; non-idempotent ones park for an operator instead of silently double-running.
  • Batches, DAGs, and scheduling are built in — fan-out, chains, arbitrary dependency graphs, cron, and one-off runs — all on the same durable substrate.

JobWarden coexists with Laravel's Bus/Queue; it does not hijack dispatch().

Requirements

  • PHP 8.3+, Laravel 11 or 12
  • Linux for the runtime (the liveness model uses POSIX signals, proc_open/pcntl, and /proc)
  • A database — best with SKIP LOCKED (PostgreSQL ≥ 9.5, MySQL ≥ 8.0.1, MariaDB ≥ 10.6); others fall back to an optimistic claim. MariaDB on RDS is the primary production target.

Installation

composer require kpconnell/laravel-jobwarden
php artisan jobwarden:install --migrate

jobwarden:install publishes config/jobwarden.php and the migrations; --migrate runs them. By default JobWarden uses a dedicated database connection (config('jobwarden.connection')) so its coordination traffic is isolated from your app's.

Defining a job

A JobWarden job implements one small contract — it receives plain, JSON-serializable params (not a serialized object graph), and declares whether it is safe to auto-retry:

use JobWarden\Contracts\JobWardenJob;
use JobWarden\Runner\JobContext;

final class ImportCatalog implements JobWardenJob
{
    public function handle(JobContext $context): void
    {
        $storeId = $context->params['store_id'];
        // ... do the work; throwing = failure, returning = success ...
    }

    public function idempotent(): bool
    {
        return true; // lost/failed runs may be safely re-executed
    }
}

Dispatching

use JobWarden\JobWarden;

app(JobWarden::class)->dispatch(ImportCatalog::class, ['store_id' => 42], [
    'idempotent'   => true,
    'max_attempts' => 3,
    'priority'     => 10,
    'available_at' => now()->addMinutes(5), // optional delay
]);

Batches (fan-out, chains, DAGs)

app(JobWarden::class)->batch('nightly-sync', failurePolicy: 'continue')
    ->add('extract',   ExtractJob::class,   ['store_id' => 42])
    ->add('transform', TransformJob::class, ['store_id' => 42], dependsOn: ['extract'])
    ->add('load',      LoadJob::class,      ['store_id' => 42], dependsOn: ['transform'])
    ->add('report',    ReportJob::class,    [],                 dependsOn: ['load'])
    ->dispatch();

A member with no dependsOn starts immediately; one with dependencies is admitted only when all of them have succeeded. Independent chains run in parallel. Failure policies: continue, fail_fast, threshold(N).

Scheduling

$jw = app(JobWarden::class);
$jw->schedule('hourly-metrics', '0 * * * *', ComputeMetrics::class);      // cron → a job
$jw->scheduleCommand('nightly-prune', '0 3 * * *', 'cache:prune');        // cron → an artisan command
$jw->scheduleOnce('one-off', now()->addHour(), SendDigest::class);        // fire once

Running the engine

JobWarden runs as long-running processes. jobwarden:work already brings its own Tier-2 local reaper (a co-resident child process), so the minimum is a worker, the global reaper, and the scheduler:

php artisan jobwarden:work          # claim + run jobs — and bundle a co-resident Tier-2 reaper
php artisan jobwarden:reap:global   # Tier-3: detect dead workers fleet-wide (leader-leased)
php artisan jobwarden:schedule      # evaluate schedules

You never have to remember jobwarden:reap:local — the worker spawns it, and a per-host lease keeps exactly one active even when several workers share a box. (It remains a standalone command for advanced split topologies.)

Each daemon should be supervised by the OS (systemd Restart=always, or a container restart policy). Unit templates are in packaging/systemd/, and a container image that runs any set of roles via a JOBWARDEN_ROLES env var is in docker/ (see docker-compose.yml for a full local stack).

→ See docs/HOSTING.md for deployment topologies: serving the UI from your existing app host, running everything on a single worker box, and how to scale out to a fleet.

How recovery works (the short version)

Every claim is stamped with the claiming worker's globally-unique id and a fencing token. A worker heartbeats a lease while it lives. When a lease goes stale, a reaper orphans that worker's in-flight attempts — bumping the fence so the presumed-dead worker can never clobber the reassignment — and recovery re-queues idempotent jobs or parks non-idempotent ones. Liveness is never the job's responsibility: jobs run in a child process the supervisor watches, so a job that blocks for an hour is never mistaken for a dead one.

Operator API & dashboard

A gated JSON API (read models + actions + scheduling) mounts under config('jobwarden.api.prefix'), and a server-rendered Livewire dashboard mounts under config('jobwarden.dashboard.prefix'). Both sit behind an authorization gate that defaults to local-only — open it explicitly:

use JobWarden\JobWarden;

JobWarden::auth(fn ($request) => $request->user()?->can('viewJobWarden') ?? false);

See docs/API.md for the full endpoint reference.

Testing

composer test                       # SQLite (fast)
# full matrix (SQLite + MariaDB + Postgres) runs in the Docker stack and in CI
docker compose run --rm migrate php vendor/bin/testbench package:test

License

MIT © Kevin Connell. See LICENSE.