Features

Queues & Jobs

Process background tasks with retries and failure handling

Offload slow operations from HTTP requests: emails, reports, image processing, webhooks, bulk imports.

Quick Start

1. Create a Job

php glueful scaffold:job SendWelcomeEmailJob   # writes app/Jobs/SendWelcomeEmailJob.php

A job extends Glueful\Queue\Job and implements handle(). The worker builds it with the data you pushed and the application context, so read input with $this->getData() and resolve services through $this->context:

namespace App\Jobs;

use Glueful\Queue\Job;
use Psr\Log\LoggerInterface;

class SendWelcomeEmailJob extends Job
{
    public function handle(): void
    {
        $data = $this->getData();

        $user = db($this->context)->table('users')
            ->where('uuid', $data['userUuid'])
            ->first();

        if ($user === null) {
            return; // Nothing to do
        }

        // ... send the email

        app($this->context, LoggerInterface::class)->info('Welcome email sent', [
            'user' => $data['userUuid'],
        ]);
    }
}

2. Dispatch the Job

use Glueful\Queue\QueueManager;
use App\Jobs\SendWelcomeEmailJob;

$queue = app($context, QueueManager::class);
$jobUuid = $queue->push(SendWelcomeEmailJob::class, ['userUuid' => $userUuid]);

push(string $job, array $data = [], ?string $queue = null, ?string $connection = null) returns the job's uuid.

Why class names + data

  • Deterministic serialization: only the job class name and a plain array are enqueued, making payloads easy to serialize and version safely.
  • Safer and portable: avoids serializing arbitrary PHP objects; drivers store compact JSON payloads that work across processes and connections.
  • Reliable retries/delays: workers rehydrate the job with the data and run handle(), ensuring consistent behavior on every attempt.
  • Clear contracts: jobs read inputs via getData(), keeping state explicit and idempotent-friendly.

3. Run a Worker

php glueful queue:work

Plain queue:work runs one worker that processes jobs until stopped.

# Process a single job, then exit
php glueful queue:work --once

# Drain the queue and exit (useful for cron / one-shot processing)
php glueful queue:work --stop-when-empty

# Target a specific connection and queues
php glueful queue:work --connection=redis --queue=high,default

queue:work options

OptionDefaultMeaning
--connectionqueue.defaultQueue connection to process
--queuedefaultQueue(s) to process, comma-separated, in priority order
--once—Process at most one job, then exit
--stop-when-empty—Exit when the queue is empty
--sleep3Seconds to sleep when no job is available
--memory, -m128Memory limit in MB
--timeout, -t60Job timeout in seconds: a job past it is released (or failed when out of attempts) and the worker exits for its process manager to restart. Needs the pcntl extension
--max-jobs0Exit after this many jobs (0 = unlimited)
--max-runtime0Exit after this many seconds (0 = unlimited)
--tries3Maximum attempts before a job is marked failed

When to Use Jobs

Use jobs for:

  • Email sending
  • Image/video processing
  • Report generation
  • API webhooks
  • Bulk operations
  • Cache warming
  • Data imports

Keep in HTTP response:

  • Critical user feedback
  • Small, fast operations
  • Required synchronous validation

Configuration

config/queue.php (excerpt):

return [
    // `database` or `redis` — the two drivers the framework ships
    'default' => env('QUEUE_CONNECTION', 'database'),

    'connections' => [
        'database' => [
            'driver' => 'database',
            'table' => 'queue_jobs',
            'queue' => 'default',
            'retry_after' => 90,
            'failed_table' => 'queue_failed_jobs',
        ],
        'redis' => [
            'driver' => 'redis',
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'port' => env('REDIS_PORT', 6379),
            'password' => env('REDIS_PASSWORD'),
            'database' => env('REDIS_DB', 0),
            'prefix' => env('REDIS_QUEUE_PREFIX', 'glueful:queue:'),
            'queue' => env('REDIS_QUEUE', 'default'),
            'retry_after' => 90,
            'job_expiration' => 3600,
        ],
    ],

    'failed' => [
        'table' => 'queue_failed_jobs',
        'max_retries' => 5,     // stored only — nothing enforces it
        'retention_days' => 30, // age at which cleanup() / queue_maintenance prune failures
    ],

    'workers' => [
        'performance' => [
            // Delay before a failed job is retried
            'backoff_strategy' => env('QUEUE_BACKOFF_STRATEGY', 'exponential'), // linear, exponential, fixed
            'backoff_base' => env('QUEUE_BACKOFF_BASE', 2),
            'max_backoff' => env('QUEUE_MAX_BACKOFF', 3600),
        ],
    ],

    'security' => [
        'payload_signing' => [
            'enabled' => env('QUEUE_PAYLOAD_SIGNING', true),
            'require_signed' => env('QUEUE_REQUIRE_SIGNED_PAYLOADS', true),
        ],
    ],
];

There are no sync or null connections (removed in 1.86 — no such drivers exist).

Payload Signing

Queue backends are mutable storage, so every payload the database and Redis drivers store is signed with an HMAC (SHA-256) keyed on APP_KEY (app.key). The worker verifies the signature before it runs a job: a payload whose class, data, attempts or timeout were altered is refused, and with QUEUE_REQUIRE_SIGNED_PAYLOADS=true an unsigned payload is refused too. Scheduled-job parameters persisted to scheduled_jobs are signed the same way.

  • Keep APP_KEY identical on every process that pushes or works jobs; changing it invalidates jobs already queued.
  • Without an APP_KEY, payloads are neither signed nor verified.

Job Basics

Simple Job

use Glueful\Queue\Job;

class RecalculateMetricsJob extends Job
{
    public function handle(): void
    {
        $accountId = $this->getData()['accountId'];

        // ... recalculate
    }
}

Dispatch

$queue->push(RecalculateMetricsJob::class, ['accountId' => $accountId]);

Delayed Jobs

Schedule jobs for later:

// Run in 5 minutes (300 seconds)
$queue->later(300, RecalculateMetricsJob::class, ['accountId' => $accountId]);

// Run tomorrow
$queue->later(86400, SendReportJob::class, ['userId' => $userId]);

Priority Queues

Use different queues for different priorities:

// High priority
$queue->push(CriticalPaymentJob::class, ['data' => $data], 'payments_high');

// Normal priority
$queue->push(SendEmailJob::class, ['userUuid' => $userUuid], 'emails');

// Low priority
$queue->push(GenerateReportJob::class, ['id' => $id], 'reports_low');

Start workers by priority:

# Process high priority first
php glueful queue:work --queue=payments_high,emails,reports_low

Retries & Failures

Automatic Retries

A job that throws is released back onto its queue until it has used min($job->getMaxAttempts(), --tries) attempts; each retry waits the backoff computed from queue.workers.performance (exponential by default). After the last attempt the worker calls the job's failed() method and the job is recorded as failed (see Failed Jobs).

use Glueful\Http\Client;
use Glueful\Queue\Job;
use Psr\Log\LoggerInterface;

class SendWebhookJob extends Job
{
    public function getMaxAttempts(): int
    {
        return 5; // default is 3; --tries still caps it
    }

    public function handle(): void
    {
        $data = $this->getData();
        $response = app($this->context, Client::class)
            ->post($data['webhookUrl'], ['json' => $data['payload'] ?? []]);

        if (!$response->isSuccessful()) {
            throw new \RuntimeException('Webhook failed: HTTP ' . $response->getStatusCode());
        }
    }

    public function failed(\Exception $exception): void
    {
        app($this->context, LoggerInterface::class)
            ->error('Webhook gave up', ['error' => $exception->getMessage()]);
    }
}

Custom Retry Logic

Call release($delay) to put the job back with a delay of your choosing, or catch an error you should not retry:

class ImportDataJob extends Job
{
    public function handle(): void
    {
        try {
            $this->importData();
        } catch (RateLimitedException $e) {
            $this->release(300); // Try again in 5 minutes
        } catch (PermanentException $e) {
            // Don't retry, just log
            app($this->context, \Psr\Log\LoggerInterface::class)
                ->error('Import failed permanently', ['error' => $e->getMessage()]);
        }
    }
}

Idempotent Jobs

Make jobs safe to retry:

class GenerateInvoicePdfJob extends Job
{
    public function handle(): void
    {
        $invoiceId = $this->getData()['invoiceId'];

        // Check if already generated
        if ($this->alreadyGenerated($invoiceId)) {
            return; // Safe no-op
        }

        $pdf = $this->generatePdf($invoiceId);
        $this->storePdf($invoiceId, $pdf);
    }
}

Bulk Operations

Dispatch many jobs efficiently:

// Warm cache for 1000 users
$userIds = range(1, 1000);

$jobs = array_map(
    fn($id) => [
        'job' => WarmUserCacheJob::class,
        'data' => ['userId' => $id],
    ],
    $userIds
);

$uuids = $queue->bulk($jobs, 'cache_warmup');

Job Chaining

There is no chaining API. Push the next job at the end of the current job's handle(), so it is queued only once the first one succeeds:

class ProcessVideoJob extends Job
{
    public function handle(): void
    {
        $data = $this->getData();

        // ... process the video

        app($this->context, QueueManager::class)
            ->push(NotifyUserJob::class, ['userId' => $data['userId'], 'message' => 'Video ready!']);
    }
}

Common Patterns

Report Generation

use Glueful\Queue\Job;
use Glueful\Queue\QueueManager;
use Glueful\Storage\StorageManager;

class GenerateMonthlyReportJob extends Job
{
    public function handle(): void
    {
        $data = $this->getData();
        $pdf = $this->generatePdf($this->gatherData());

        // Store the report on the default disk
        app($this->context, StorageManager::class)
            ->put("reports/monthly-{$data['month']}.pdf", $pdf);

        // Notify user
        app($this->context, QueueManager::class)
            ->push(NotifyReportReadyJob::class, ['userId' => $data['userId']]);
    }
}

For email, prefer Notifications, which can queue delivery for you.

Monitoring

// Queue depth
$pending = $queue->size('default');

// Failed jobs (database driver)
$failed = db($context)->table('queue_failed_jobs')->count();

// Oldest waiting job (database driver)
$oldest = db($context)->table('queue_jobs')
    ->orderBy('created_at', 'asc')
    ->first();

Failed Jobs

A job that exhausted its attempts lands in queue_failed_jobs (database driver) or the queue:{name}:failed list (Redis driver). Manage failures from the CLI:

php glueful queue:failed                   # List: job class, queue, error (--queue= --limit=50 --json)
php glueful queue:retry <uuid> [<uuid>…]   # Put them back on their queue as new jobs
php glueful queue:retry --all [--queue=emails]
php glueful queue:forget <uuid>            # Delete one
php glueful queue:flush [--queue=emails]   # Delete them all

Each takes --connection= (default: the configured connection). A retry verifies the stored payload's signature first, so a payload altered after it failed is refused, never re-signed.

From code, use FailedJobProvider (database driver):

use Glueful\Queue\Failed\FailedJobProvider;

$provider = app($context, FailedJobProvider::class);

// Filters: connection, queue, from_date, to_date
$failed = $provider->all(['queue' => 'emails'], limit: 50);

$provider->retry($uuid);   // bool
$provider->forget($uuid);  // bool
$provider->retryAll();     // retry every failed job
$provider->prune(30);      // delete failures older than 30 days; returns the count
$provider->cleanup();      // prune at the provider's retention age; bool

queue.failed.* configures both the container-resolved provider above and the one the database driver builds ($queue->connection('database')->failures()); retention_days (default 30) is the age cleanup() prunes at. The built-in queue_maintenance scheduled job (Glueful\Queue\Jobs\QueueMaintenanceJob, daily at 04:15, QUEUE_MAINTENANCE_ENABLED) prunes failures past that age for you (queue.maintenance.failed_jobs_cleanup_days overrides the age for that pass). An app with its own config/schedule.php must list the job to get this pruning. queue.failed.max_retries is stored but not enforced — a retry is a new job with fresh attempts, bounded by the worker's --tries.

Best Practices

Keep Jobs Small

// ✅ Good - focused, retryable
class SendEmailJob extends Job
{
    public function handle(): void
    {
        $this->send($this->getData());
    }
}

// ❌ Bad - too many responsibilities
class ProcessUserRegistrationJob extends Job
{
    public function handle(): void
    {
        // Send email
        // Update analytics
        // Notify admin
        // Create account
        // Generate invoice
        // ... (too much)
    }
}

Make Idempotent

// ✅ Good - safe to retry
public function handle(): void
{
    if ($this->alreadyProcessed()) {
        return;
    }
    $this->process();
}

// ❌ Bad - creates duplicates on retry
public function handle(): void
{
    $this->createRecord(); // Creates duplicate!
}

Throw on Failure

// ✅ Good - allows retry
public function handle(): void
{
    if (!$this->apiCall()) {
        throw new \RuntimeException('API failed');
    }
}

// ❌ Bad - hides failures
public function handle(): void
{
    try {
        $this->apiCall();
    } catch (\Exception $e) {
        // Silently fails
    }
}

Troubleshooting

Jobs not processing?

  • Ensure a worker is running: php glueful queue:work
  • Check QUEUE_CONNECTION in .env (database or redis)
  • "Refusing to run job class" in the log: the class does not implement JobInterface
  • "signature mismatch" / "missing its HMAC signature": the payload was pushed with a different APP_KEY, or without one

Jobs failing?

  • php glueful queue:failed shows the error for each failure
  • Make the job idempotent, then php glueful queue:retry <uuid>

Queue growing?

  • Scale workers
  • Optimize job performance
  • Split into priority queues

Jobs timing out?

  • Raise --timeout (and retry_after in config)
  • Break large jobs into smaller ones

Scaling Workers

Single Worker

php glueful queue:work

Multiple Workers

Core's queue:work runs a single worker per invocation. To run more than one, start several processes — either by hand, or (recommended for production) under a process supervisor.

# Terminal 1
php glueful queue:work --queue=high

# Terminal 2
php glueful queue:work --queue=default

# Terminal 3
php glueful queue:work --queue=low

Supervisor (Production)

Run multiple lean workers under your OS process manager (Supervisor, systemd):

/etc/supervisor/conf.d/queue-worker.conf:

[program:queue-worker]
command=php /path/to/glueful queue:work --queue=default --max-runtime=3600
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
user=www-data
numprocs=4

Deploying New Code

Workers are long-running processes that keep the code they started with. As the last step of each deploy, ask them to restart:

php glueful queue:restart

Every running worker exits after its current job, and your supervisor starts it again on the new code. The signal is a timestamp in the cache, so the workers need a configured cache store they share (without one, nothing is signalled); a worker started after the signal keeps running.

Managed Fleets & Autoscaling

Next Steps