Appearance
Queues & Real-Time Communication
Queues
- Driver: Redis (configured in
config/queue.php);syncin tests. - Management: Laravel Horizon (
config/horizon.php). - In development, Horizon runs in its own
horizoncontainer, started with the rest of the stack by./vendor/bin/sail up -d.spatie/laravel-horizon-watcherrestarts it whenever a watched PHP file changes, so workers never run stale code — watched paths inconfig/horizon-watcher.php. You do not need to runqueue:workorqueue:listenyourself. - Queued work is also commonly expressed as an Action (
AsJobon alorisleiva/laravel-actionsclass) rather than a dedicatedapp/Jobsclass. - Never pass a
Shopinto a queued job — resolve the tenant withShop::current()inside the job. - The current tenant travels with the job through Laravel's Context:
AppServiceProviderdehydratescurrent_shop_id(orcurrent_client_id) into the context when a job is dispatched and callsmakeCurrent()again on hydration, soShop::current()inside the worker is the shop that dispatched the job. Jobs dispatched from a console command with no current shop get no tenant — callmakeCurrent()yourself first.
bash
docker compose logs -f horizon
docker compose restart horizonRecommended Job Structure
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.phporbootstrap/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.