Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Backend Features/Queue & Jobs

Queue & Jobs Guide

Last Updated: 2026-01-12 Status: Active Audience: Developers Plan Reference: 085-background-jobs-improvement.md

This guide documents queue and job patterns for the Client Portal application.


Table of Contents

  1. Overview
  2. Queue Monitoring Dashboard
  3. Job Metrics Tracking
  4. Creating Jobs
  5. Dispatching Jobs
  6. Job Configuration
  7. Error Handling
  8. Testing Jobs
  9. Queue Workers

Overview

Queues allow deferring time-consuming tasks:

  • Sending emails
  • Generating PDFs
  • Processing uploads
  • External API calls
  • Data exports

Queue Drivers

DriverUse Case
syncDevelopment (immediate execution)
databaseProduction (simple setup)
redisProduction (high performance)
sqsAWS deployments

Queue Monitoring Dashboard

The admin panel includes a comprehensive queue monitoring dashboard at /admin/queues.

Accessing the Dashboard

Navigate to Admin → System → Queue Dashboard to access monitoring features.

Dashboard Features

FeatureDescription
Queue StatusReal-time sizes and health of all queues
Worker StatusActive workers, memory usage, uptime
Job MetricsProcessed, failed, avg duration today
Recent JobsList of recently processed jobs
Failed JobsList of failed jobs with retry/delete actions

Routes

RouteMethodDescription
admin.queues.indexGETMain dashboard
admin.queues.showGETIndividual queue details
admin.queues.analyticsGETAnalytics and trends
admin.queues.prioritiesGETQueue priority configuration
admin.queues.realtimeGETReal-time metrics (AJAX)

Queue Configuration

Configure monitored queues in config/queue.php:

'monitored_queues' => ['default', 'high', 'low', 'emails'],

Job Metrics Tracking

All job executions are automatically tracked via the JobEventSubscriber.

JobMetric Model

Location: app/Models/JobMetric.php

use App\Models\JobMetric;

// Query today's completed jobs
$completed = JobMetric::completed()->today()->count();

// Query failed jobs for a queue
$failed = JobMetric::failed()->forQueue('emails')->get();

// Get average duration
$avgDuration = JobMetric::completed()->today()->avg('duration_ms');

Recorded Data

FieldTypeDescription
job_iduuidUnique job identifier
queuestringQueue name
job_classstringFull class name
statusstringpending/processing/completed/failed
queued_attimestampWhen job was queued
started_attimestampWhen processing began
completed_attimestampWhen processing finished
duration_msfloatExecution time in milliseconds
memory_mbfloatMemory used during execution
exceptiontextError message if failed
metadatajsonAdditional job data

Job Status Events

The JobStatusUpdated event broadcasts to the jobs channel:

Echo.channel('jobs')
    .listen('.status.updated', (e) => {
        console.log(e.short_name, e.status, e.duration_ms);
    });

Scopes

// Filter by status
JobMetric::completed()->get();
JobMetric::failed()->get();
JobMetric::processing()->get();

// Filter by queue
JobMetric::forQueue('emails')->get();

// Filter by job class
JobMetric::forJobClass('App\\Jobs\\SendEmail')->get();

// Filter by time
JobMetric::today()->get();
JobMetric::since(now()->subHours(6))->get();

Creating Jobs

Generate a Job

php artisan make:job ProcessInvoicePdf
php artisan make:job SendInvoiceEmail

Basic Job Structure

<?php

namespace App\Jobs;

use App\Models\Invoice;
use App\Services\PdfService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessInvoicePdf implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     */
    public function __construct(
        public Invoice $invoice
    ) {}

    /**
     * Execute the job.
     */
    public function handle(PdfService $pdfService): void
    {
        $path = $pdfService->generateInvoicePdf($this->invoice);

        $this->invoice->update(['pdf_path' => $path]);
    }
}

Job with Dependencies

<?php

namespace App\Jobs;

use App\Models\Invoice;
use App\Mail\InvoiceMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

class SendInvoiceEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Invoice $invoice,
        public ?string $customMessage = null
    ) {}

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        // Load relationships needed for email
        $this->invoice->load(['client', 'items']);

        Mail::to($this->invoice->client->email)
            ->send(new InvoiceMail($this->invoice, $this->customMessage));

        $this->invoice->update(['emailed_at' => now()]);
    }
}

Dispatching Jobs

Basic Dispatch

use App\Jobs\ProcessInvoicePdf;

// Dispatch to default queue
ProcessInvoicePdf::dispatch($invoice);

// Dispatch with arguments
SendInvoiceEmail::dispatch($invoice, 'Please review this invoice.');

Dispatch Options

// Delay execution
ProcessInvoicePdf::dispatch($invoice)
    ->delay(now()->addMinutes(5));

// Specific queue
ProcessInvoicePdf::dispatch($invoice)
    ->onQueue('pdfs');

// Specific connection
ProcessInvoicePdf::dispatch($invoice)
    ->onConnection('redis');

// Chain jobs
ProcessInvoicePdf::dispatch($invoice)
    ->chain([
        new SendInvoiceEmail($invoice),
        new LogInvoiceSent($invoice),
    ]);

Dispatch After Response

// Dispatch after HTTP response is sent
ProcessInvoicePdf::dispatchAfterResponse($invoice);

Conditional Dispatch

// Only dispatch if condition is true
ProcessInvoicePdf::dispatchIf($invoice->shouldGeneratePdf(), $invoice);

// Dispatch unless condition is true
ProcessInvoicePdf::dispatchUnless($invoice->hasPdf(), $invoice);

Synchronous Dispatch

// Execute immediately (bypass queue)
ProcessInvoicePdf::dispatchSync($invoice);

Job Configuration

Retry Configuration

class ProcessInvoicePdf implements ShouldQueue
{
    /**
     * Number of times to attempt the job.
     */
    public int $tries = 3;

    /**
     * Number of seconds to wait before retrying.
     */
    public int $backoff = 60;

    /**
     * Exponential backoff times.
     */
    public array $backoff = [60, 120, 300];

    /**
     * Maximum exceptions before failing.
     */
    public int $maxExceptions = 3;
}

Timeout Configuration

class ProcessLargeExport implements ShouldQueue
{
    /**
     * Job timeout in seconds.
     */
    public int $timeout = 300; // 5 minutes

    /**
     * Prevent job from timing out during processing.
     */
    public bool $failOnTimeout = true;
}

Unique Jobs

use Illuminate\Contracts\Queue\ShouldBeUnique;

class ProcessInvoicePdf implements ShouldQueue, ShouldBeUnique
{
    /**
     * Unique ID for preventing duplicates.
     */
    public function uniqueId(): string
    {
        return $this->invoice->id;
    }

    /**
     * Seconds the unique lock should be maintained.
     */
    public int $uniqueFor = 3600;
}

Queue Priority

class SendInvoiceEmail implements ShouldQueue
{
    /**
     * Assign to high priority queue.
     */
    public function __construct(public Invoice $invoice)
    {
        $this->onQueue('high');
    }
}

// Or when dispatching
SendInvoiceEmail::dispatch($invoice)->onQueue('high');

Error Handling

Failed Job Handling

class ProcessInvoicePdf implements ShouldQueue
{
    public function handle(PdfService $pdfService): void
    {
        $pdfService->generateInvoicePdf($this->invoice);
    }

    /**
     * Handle job failure.
     */
    public function failed(\Throwable $exception): void
    {
        // Log the failure
        Log::error('Invoice PDF generation failed', [
            'invoice_id' => $this->invoice->id,
            'error' => $exception->getMessage(),
        ]);

        // Notify admin
        Notification::route('mail', config('client_portal.admin_email'))
            ->notify(new JobFailedNotification($this, $exception));

        // Update invoice status
        $this->invoice->update(['pdf_status' => 'failed']);
    }
}

Retry Specific Exceptions

use App\Exceptions\TemporaryPdfException;

class ProcessInvoicePdf implements ShouldQueue
{
    /**
     * Exceptions that should trigger retry.
     */
    public function retryUntil(): \DateTime
    {
        return now()->addHours(1);
    }

    /**
     * Determine if exception should be reported.
     */
    public function shouldRetry(\Throwable $e): bool
    {
        return $e instanceof TemporaryPdfException;
    }
}

Manual Fail

public function handle(): void
{
    if (!$this->invoice->client) {
        $this->fail('Invoice has no associated client.');
        return;
    }

    // Process...
}

Release Back to Queue

public function handle(): void
{
    if ($this->someConditionNotMet()) {
        // Put back on queue with delay
        $this->release(60);
        return;
    }

    // Process...
}

Testing Jobs

Test Job Dispatch

use App\Jobs\ProcessInvoicePdf;
use Illuminate\Support\Facades\Queue;

public function test_invoice_pdf_job_dispatched_on_send(): void
{
    Queue::fake();

    $invoice = Invoice::factory()->create();

    $this->post(route('invoices.send', $invoice));

    Queue::assertPushed(ProcessInvoicePdf::class, function ($job) use ($invoice) {
        return $job->invoice->id === $invoice->id;
    });
}

Test Job Execution

use App\Jobs\ProcessInvoicePdf;
use App\Services\PdfService;

public function test_invoice_pdf_is_generated(): void
{
    $invoice = Invoice::factory()->create();

    $pdfService = $this->mock(PdfService::class);
    $pdfService->shouldReceive('generateInvoicePdf')
        ->once()
        ->with($invoice)
        ->andReturn('invoices/123.pdf');

    $job = new ProcessInvoicePdf($invoice);
    $job->handle($pdfService);

    $this->assertEquals('invoices/123.pdf', $invoice->fresh()->pdf_path);
}

Test Job Chaining

public function test_jobs_are_chained(): void
{
    Queue::fake();

    $invoice = Invoice::factory()->create();

    ProcessInvoicePdf::dispatch($invoice)->chain([
        new SendInvoiceEmail($invoice),
    ]);

    Queue::assertPushedWithChain(ProcessInvoicePdf::class, [
        SendInvoiceEmail::class,
    ]);
}

Test Failed Jobs

public function test_failed_job_notifies_admin(): void
{
    Notification::fake();

    $invoice = Invoice::factory()->create();
    $job = new ProcessInvoicePdf($invoice);
    $exception = new \Exception('PDF generation failed');

    $job->failed($exception);

    Notification::assertSentTo(
        Notification::route('mail', config('client_portal.admin_email')),
        JobFailedNotification::class
    );
}

Queue Workers

Running Workers

# Process all queues
php artisan queue:work

# Process specific queue
php artisan queue:work --queue=high,default

# Process single job then exit
php artisan queue:work --once

# Limit memory and time
php artisan queue:work --memory=128 --timeout=60

# Sleep between jobs
php artisan queue:work --sleep=3

Supervisor Configuration

; /etc/supervisor/conf.d/client-portal-worker.conf
[program:client-portal-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/client-portal/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/client-portal/storage/logs/worker.log
stopwaitsecs=3600

Restart Workers After Deploy

# Signal workers to restart after current job
php artisan queue:restart

Common Job Patterns

Batch Processing

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$invoices = Invoice::where('status', 'draft')->get();

$jobs = $invoices->map(fn ($invoice) => new SendInvoiceEmail($invoice));

$batch = Bus::batch($jobs)
    ->then(function (Batch $batch) {
        // All jobs completed successfully
        Log::info('Batch completed', ['id' => $batch->id]);
    })
    ->catch(function (Batch $batch, \Throwable $e) {
        // First failure detected
        Log::error('Batch failed', ['error' => $e->getMessage()]);
    })
    ->finally(function (Batch $batch) {
        // Batch finished (success or failure)
    })
    ->name('Send Invoices')
    ->dispatch();

Rate Limited Jobs

use Illuminate\Queue\Middleware\RateLimited;

class SendInvoiceEmail implements ShouldQueue
{
    public function middleware(): array
    {
        return [new RateLimited('emails')];
    }
}

// In AppServiceProvider
RateLimiter::for('emails', function ($job) {
    return Limit::perMinute(30);
});

Preventing Overlapping Jobs

use Illuminate\Queue\Middleware\WithoutOverlapping;

class ProcessClientReport implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            (new WithoutOverlapping($this->client->id))
                ->releaseAfter(60)
                ->expireAfter(300),
        ];
    }
}

Best Practices

Do

  • Keep jobs small and focused
  • Use job chaining for sequential tasks
  • Handle failures gracefully
  • Set appropriate timeouts
  • Use unique jobs to prevent duplicates
  • Test job dispatch and execution

Don't

  • Access request/session in jobs
  • Perform heavy database queries in jobs without chunking
  • Forget to handle exceptions
  • Use sync driver in production
  • Queue jobs for instant operations