Skip to content

Queues & Real-Time Communication

Queues

  • Driver: Redis (configured in config/queue.php); sync in tests.
  • Management: Laravel Horizon (config/horizon.php).
  • In development, Horizon runs in its own horizon container, started with the rest of the stack by ./vendor/bin/sail up -d. spatie/laravel-horizon-watcher restarts it whenever a watched PHP file changes, so workers never run stale code — watched paths in config/horizon-watcher.php. You do not need to run queue:work or queue:listen yourself.
  • Queued work is also commonly expressed as an Action (AsJob on a lorisleiva/laravel-actions class) rather than a dedicated app/Jobs class.
  • Never pass a Shop into a queued job — resolve the tenant with Shop::current() inside the job.
  • The current tenant travels with the job through Laravel's Context: AppServiceProvider dehydrates current_shop_id (or current_client_id) into the context when a job is dispatched and calls makeCurrent() again on hydration, so Shop::current() inside the worker is the shop that dispatched the job. Jobs dispatched from a console command with no current shop get no tenant — call makeCurrent() yourself first.
bash
docker compose logs -f horizon
docker compose restart horizon
php
declare(strict_types=1);

namespace App\Jobs;

use App\Models\Shop;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

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

    public function __construct(public readonly int $workOrderId) {}

    public function handle(): void
    {
        $shop = Shop::current();

        // Fetch the work order & generate the PDF
    }
}

Best Practices

  • Keep job payloads minimal (IDs, not entire models).
  • Handle retries gracefully; idempotency is critical for external side effects.
  • Use failed() method for custom failure handling if needed.
  • Utilize Horizon tags to group related jobs.

Real-Time (WebSockets)

  • Server: Laravel Reverb.
  • Client: laravel-echo + pusher-js.
  • Broadcasting config: config/reverb.php & config/broadcasting.php.
  • Channels defined in routes/channels.php (authorization callbacks).

Model Broadcasting

Every model extending App\Models\Model uses Laravel's BroadcastsEvents trait. Broadcasts go out on the per-instance channel only:

models.{morphAlias}.{id}

ModelServiceProvider auto-registers a morph map over every concrete class in app/Models, so the alias is the model's table name (work_orders, not App\Models\WorkOrder). The same scan registers implicit route-model bindings for the camel-cased short name.

The per-instance restriction is deliberate: a class-wide models.{alias} channel would authorize through viewAny(), which is not shop-scoped, and would leak newly created records across tenants. Events are also never echoed back to the originating user (dontBroadcastToCurrentUser()).

Example Custom Broadcast Event

php
declare(strict_types=1);

namespace App\Events;

use App\Models\WorkOrder;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class WorkOrderStatusUpdated implements ShouldBroadcastNow
{
    use Dispatchable;
    use InteractsWithSockets;
    use SerializesModels;

    public function __construct(public WorkOrder $workOrder) {}

    public function broadcastOn(): PrivateChannel
    {
        return new PrivateChannel('work-orders.' . $this->workOrder->id);
    }
}

Frontend Listener

Echo is configured once in @/Plugins/broadcasting.ts (broadcaster: 'reverb') — never construct a second instance in a component. Go through the useBroadcasting() helper, which manages channel binding and cleanup:

ts
import { useBroadcasting } from '@/Plugins/broadcasting.ts';

useBroadcasting().bindModelChannel('work_orders', workOrderId);
useBroadcasting().listenToShopChannel(shopId, 'NewMessage', () => { /* … */ });

Available channel families: global, users.{id}, users.{id}.{name} (sub-user), models.{alias}[.{id}], shop.{id}, and an AI channel. Model events broadcast as .created / .updated / .deleted (see Model::broadcastAs()), so the leading dot is required when listening directly.

Always unbind on unmount — AppLayout.vue and InboxDrawer.vue are the reference examples.

Webhooks

Inbound external triggers processed via spatie/laravel-webhook-client.

  • Verify signatures in middleware profile.
  • Map payload to normalized DTO / ValueObject.
  • Dispatch Actions / Jobs for asynchronous processing.

Scheduled Tasks

  • Define console schedule in routes/console.php or bootstrap/app.php (no legacy Kernel).
  • Use queue for heavy scheduled operations (e.g., daily tire storage audit).

Monitoring & Observability

  • Horizon dashboard (/horizon) for queue throughput & failure rates.
  • Pulse (/pulse) for slow jobs, slow queries, 4xx responses and scheduled-task listings.
  • Sentry monitors performance of broadcast events & job executions.
  • Activity log records significant domain changes for auditing.

Failure Handling

  • Automatic retries based on queue configuration.
  • Use alerting (Sentry issue notifications) for recurrent job failures.
  • Maintain idempotent job behavior to avoid data duplication on retries.

See types.md for how domain data propagates to the frontend.