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.
  • Hydration clears the tenant before it applies one. makeCurrent() binds a container instance that the worker's scoped-instance reset does not clear, so without an unconditional forgetCurrent() a worker keeps the previous job's garage current and stamps the next job's rows with it. A job dispatched with no tenant must land with no tenant, not with the last one. Covered by tests/Feature/QueueTenantContextTest.php.
  • A tenant faked in a test (Shop::fakeCurrent()) is pinned for the whole test and is left alone by the hydration hook — clearing it would only unpin what the test asked for.
  • A context that names a shop or client that no longer exists fails loudly (findOrFail) rather than running the job untenanted.
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).

Notification Broadcasting ​

Notifications broadcast on users.{id} rather than Laravel's default App.Models.User.{id} — User::receivesBroadcastNotificationsOn() points them at the channel the app already authorizes. The client side is broadcasting.listenToUserNotifications(), which uses Echo's own notification() listener rather than naming the broadcast event. See Notifications.

Model Broadcasting ​

Every model extending App\Models\Model mixes in Laravel's BroadcastsEvents trait, but broadcasting is opt-in. The base class inverts the framework default:

php
protected static $isBroadcasting = false;

A model stays silent until it sets that back to true. This is the single biggest trap in the system: defining broadcastOn() and broadcastWith() is not enough and fails with no symptom, because broadcastIfBroadcastChannelsExistForEvent() returns before it ever asks for a channel. RealtimeModelsBroadcastTest pins the current opted-in set.

Broadcasts go out on the per-instance channel:

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 a route binder for the camel-cased short name (scope-aware, see Multi-tenancy).

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()).

The payload is a signal, not the record ​

Model::broadcastWith() ships identifiers only:

php
['id' => …, 'model' => …, 'causer' => …]

Never add attributes. Without the override Laravel falls back to toArray() and ships the whole row; a payload past Reverb's frame size throws inside the writing transaction and rolls the write back. Message hand-rolled this workaround after a rendered HTML mail did exactly that.

Clients therefore treat every event as a nudge and refetch — they never read data off the event.

Delivery timing ​

  • broadcastAfterCommit() is true, so a queued broadcast waits for the transaction to commit. Subscribers refetch on receipt; announcing a write that has not committed (or never will) would have them read the old row.
  • BroadcastableModelEventOccurred::shouldBroadcastNow() returns true outside production, so local development needs no worker. That path publishes from inside the writing transaction, which is why the event implements ShouldRescue: a transport error is swallowed rather than allowed to abort the write. A missed signal costs a stale page; a rolled-back write costs the record.
  • The override keeps the parent's rule for hard deletes. A queued broadcast restores its model by primary key, so a model without SoftDeletes must publish synchronously or the worker finds nothing and deleteWhenMissingModels drops the job silently.

Children fan out to the root ​

A show page should subscribe to one channel and hear about everything beneath it. App\Traits\BroadcastsToParent puts a child's events on its parent's channel as well as its own:

ModelAlso broadcasts on
WorkOrderLine, Inspectionwork_orders.{id}
InspectionResultinspections.{id}
Transaction (incl. payments)its morph parent
Product (a variant)products.{parent_product_id}
StockMutationwhatever it moves stock for
PurchaseOrderLinewhatever it orders

Build the channel from the foreign key, never the relation — a relation access fires a query on every single write. Return null when the parent is genuinely optional (a top-level product, a line pointing at something other than a product) and the event stays on the model's own channel.

Morph columns already hold the channel's model segment, so transactable_type and stockable_type are used directly rather than through a lookup array.

Authorization ​

routes/channels.php gates models.{modelName}.{modelId} with $user->can('view', $record), so every model a page subscribes to needs a policy with view(). Without one the gate resolves to nothing, the channel 403s for everyone, and the feature is dead with no failure anywhere but the browser console.

The channel resolves the record with find(), never findOrNew(): a view() that walks a relation would turn an unknown id into a 500 instead of a denial, and clients re-authorize every channel on reconnect.

Fanning a child out to a root channel exposes it to everyone who can view that root — inventory levels and supplier pricing ride on the product channel, so its view gate is the only thing between tenants.

Frontend: show pages ​

Show pages use the useRealtime hook rather than touching Echo:

ts
useRealtime({
    channels: () => [`work_orders.${props.workOrder.id}`],
    props: {
        work_orders: ['workOrder'],
        work_order_lines: ['workOrder'],
    },
});

channels is a getter, not an array: a partial reload patches props in place without remounting, so a root that only appears later (the first payment, the first inspection) would never be subscribed by a list read once in setup(). props maps a broadcasting model name (its table) to the Inertia props it invalidates.

Events are signals, so the hook coalesces a burst — capped from its first event rather than re-armed, so two people autosaving cannot starve it — and issues one router.reload({ only }).

Frontend: protecting edits ​

An autosave form registers itself so a live update cannot reload it away:

ts
useRealtime().guard(form);

With no options the hook injects the page's instance and no-ops on pages that never subscribed. A form owned by the subscribing page itself must guard through the return value (const realtime = useRealtime({…}); realtime.guard(form)) — inject() resolves from the parent, so a second call in the same component silently gets the inert fallback.

While any guarded form is dirty the page goes stale and <RealtimeBanner> offers a reload instead of clobbering the edit. Two consequences worth knowing:

  • Every autosave form on a subscribed page needs a guard; one unguarded form is a hole the banner cannot cover.
  • A guarded form has to be able to go clean again. A modal that hides without resetting its form pins the page stale for the rest of its life.

The banner reloads through router.visit(…, { preserveState: false }). router.reload() hard-codes preserveState: true after spreading its options, so it would keep the very form state the button promises to discard.

Frontend: keeping seeded forms honest ​

A form seeded from props is seeded once, and a partial reload patches props without remounting. Fields mirroring server state need syncFormFromServer(form, () => ({ … })) from @/Hooks/useSyncedForm.ts or the inputs keep showing what the page opened with.

It adopts a field only while the form still holds the previous server value — once the user has typed into it the field is theirs, because overwriting it would rebaseline the edit away and the guard could no longer tell anything was at stake. It also stands down while the form is processing, since Inertia skips its own onSuccess rebaseline as soon as defaults() has been called mid-submit.

Prefer it over a hand-rolled watch that assigns every field: a whole-form form.defaults() clears isDirty outright, which silently disarms the guard.

A form seeded empty and filled in afterwards reports dirty for its whole life, so every live update stops at the stale banner on a page nobody has touched. Seed useForm from the props instead.

Frontend: low-level channels ​

For anything that is not a show page, @/Plugins/broadcasting.ts owns the single Echo instance (broadcaster: 'reverb') — never construct a second one in a component. Go through useBroadcasting(), which manages 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.

The same module registers an http.onRequest interceptor attaching X-Socket-ID to every Inertia request. Laravel reads that header to keep a write from echoing back to the connection that made it; Inertia v3 dropped axios, which used to attach it, so without the interceptor dontBroadcastToCurrentUser() has nothing to exclude and every editor reacts to their own changes.

Testing broadcasts ​

The suite defaults to the log broadcaster, whose auth() answers 200 to everyone — a channel-authorization test written against it passes while proving nothing. Switching to a real driver is not enough on its own: channels register against whichever driver was default when routes/channels.php loaded, so a fresh driver starts with no channels and denies everyone, which makes denial assertions pass for the wrong reason. Re-require the channel file after switching. WorkOrderRealtimeChannelTest and ProductRealtimeChannelTest are the reference examples.

Opting a model in has a test-side consequence: factories now broadcast for real, so a test that switches broadcasting.default to a live connection must build its fixtures before the switch or the publish throws.

Specs mounting a guarded partial must mock @/Plugins/broadcasting.ts — importing the hook pulls in the Echo plugin, whose module-level watcher reads page props.

Inter-Service Messaging ​

This application exposes no webhook endpoints. Provider webhooks (Twilio, Mailgun) reach comms-service on its own hostname, and the two services talk over RabbitMQ for anything that changes state.

  • app/Bus/ publishes through a transactional outbox (bus:outbox:relay).
  • app/Consumers/CommsEventConsumer handles inbound delivery events (bus:consume app.comms).
  • Topology in config/bus.php; product settings in config/comms.php.

See microservices/comms-service-design.md and microservices/runbook.md.

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.