Support Desk
In this example, we'll build the most common use of Beam: a support desk where an agent opens a ticket, calls the customer with one click, shares a screen, sends a file inside the call, and the ticket keeps a record of what happened. Plain Blade, no build step for Beam — this is the Switchboard demo, trimmed to its essentials.
Step 1: Decide Who May Call Whom
<?php
namespace App\Providers;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// An agent may ring a customer who has an open ticket. Nobody else may ring anyone.
Gate::define('beam.call', fn (User $caller, User $callee) =>
$caller->isAgent() && Ticket::open()->where('customer_id', $callee->id)->exists());
}
}Step 2: Ring From the Ticket
<?php
namespace App\Http\Controllers;
use App\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RealRashid\Beam\Facades\Beam;
class CallController extends Controller
{
public function ring(Request $request, Ticket $ticket): View
{
abort_unless($request->user()->isAgent(), 403);
$call = Beam::call($ticket->customer)
->video()
->about("Ticket #{$ticket->id}: {$ticket->subject}") // what the customer sees before answering
->timeout(45)
->ring();
return view('calls.show', ['ticket' => $ticket, 'token' => $call->token]);
}
}ring() checks the gate, mints the tokens, broadcasts to the customer's open tabs, sends a push to their closed ones, and schedules the timeout. The view gets a token and nothing else.
Step 3: The Agent's Call Screen
{{-- resources/views/calls/show.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="grid gap-6 md:grid-cols-[1fr_300px]">
<div class="relative overflow-hidden rounded-xl bg-slate-900 text-slate-100">
<video id="beam-remote" autoplay playsinline class="aspect-video w-full bg-black"></video>
<video id="beam-local" autoplay muted playsinline class="absolute right-3 top-3 w-40 rounded-lg bg-black"></video>
<div class="absolute left-3 top-3 rounded-full bg-black/60 px-3 py-1 text-sm"><span data-beam="state">ringing</span> · <span data-beam="duration">0:00</span></div>
<div data-beam="banner" hidden class="absolute left-3 top-12 rounded bg-amber-500 px-3 py-1 text-sm text-black">Reconnecting…</div>
<div class="flex flex-wrap justify-center gap-2 p-3">
<button data-beam="mute" class="rounded-full bg-slate-700 px-4 py-2 text-sm">Mute</button>
<button data-beam="camera" class="rounded-full bg-slate-700 px-4 py-2 text-sm">Camera off</button>
<button data-beam="share" class="rounded-full bg-slate-700 px-4 py-2 text-sm">Share screen</button>
<label class="cursor-pointer rounded-full bg-slate-700 px-4 py-2 text-sm"> Send a file <input id="file" type="file" hidden></label>
<button data-beam="hangup" class="rounded-full bg-red-600 px-4 py-2 text-sm">End</button>
</div>
<p id="transfer" class="px-3 pb-3 text-sm text-slate-400"></p>
</div>
<aside class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-xs uppercase tracking-wide text-slate-500">Ticket #{{ $ticket->id }}</p>
<p class="mt-1 font-medium">{{ $ticket->subject }}</p>
<p class="mt-3 text-sm">Calling <b>{{ $ticket->customer->name }}</b></p>
</aside>
</div>
@endsection
@push('scripts')
<script>
(async () => {
const $ = (name) => document.querySelector(`[data-beam="${name}"]`);
const clock = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
const call = await Beam.join(@js($token));
call.attach({ local: '#beam-local', remote: '#beam-remote' });
call.on('state', (s) => { $('state').textContent = s; $('banner').hidden = s !== 'reconnecting'; });
call.on('tick', ({ seconds }) => { $('duration').textContent = clock(seconds); });
call.on('muted', (m) => { $('mute').textContent = m ? 'Unmute' : 'Mute'; });
call.on('camera', (on) => { $('camera').textContent = on ? 'Camera off' : 'Camera on'; });
call.on('sharing', (on) => { $('share').textContent = on ? 'Stop sharing' : 'Share screen'; });
call.on('ended', ({ reason, duration }) => {
$('state').textContent = `ended (${reason}, ${clock(duration)})`;
setTimeout(() => { window.location = @js(route('tickets.show', $ticket)); }, 2500);
});
$('mute').onclick = () => call.toggleMute();
$('camera').onclick = () => (call.isCameraOn ? call.cameraOff() : call.cameraOn());
$('share').onclick = () => (call.isSharing ? call.stopSharing() : call.shareScreen()).catch((e) => { $('state').textContent = e.message; });
$('hangup').onclick = () => call.hangUp();
// A file inside the call: the call's own connection carries it.
document.getElementById('file').onchange = async (e) => {
const transfer = Beam.send(e.target.files[0]).to(@js($ticket->customer->id));
transfer.on('progress', ({ percent }) => { document.getElementById('transfer').textContent = `${e.target.files[0].name} · ${percent}%`; });
try { await transfer.start(); document.getElementById('transfer').textContent = 'Delivered '; }
catch (err) { document.getElementById('transfer').textContent = err.reason === 'declined' ? 'Declined' : err.message; }
};
})();
</script>
@endpushStep 4: The Customer Is Rung Anywhere
Every page the customer keeps open includes the incoming-call modal and the file-offer box. Put them in the layout once; beam:install --views copied beam.incoming for you.
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Support</title>
@vite(['resources/css/app.css', 'resources/js/app.js']) {{-- window.Echo --}}
@beamAssets {{-- window.Beam --}}
</head>
<body>
@yield('content')
@auth
@include('beam.incoming')
@include('partials.file-offers')
@endauth
@stack('scripts')
</body>
</html>{{-- resources/views/partials/file-offers.blade.php --}}
<div id="file-offer" hidden class="fixed bottom-6 right-6 w-80 rounded-xl border bg-white p-4 shadow-lg">
<p class="text-sm"><b data-file="from"></b> is sending <b data-file="name"></b></p>
<div class="mt-3 h-2 rounded bg-slate-100"><div data-file="bar" class="h-full w-0 bg-emerald-500"></div></div>
<button data-file="accept" class="mt-3 rounded-full bg-emerald-600 px-4 py-1.5 text-sm text-white">Accept</button>
<button data-file="decline" class="mt-3 rounded-full bg-slate-200 px-4 py-1.5 text-sm">Decline</button>
</div>
<script>
Beam.onFileOffered((offer) => {
const box = document.getElementById('file-offer');
const $ = (n) => box.querySelector(`[data-file="${n}"]`);
$('from').textContent = offer.from.name;
$('name').textContent = offer.name;
box.hidden = false;
$('decline').onclick = () => { box.hidden = true; offer.decline(); };
$('accept').onclick = async () => {
offer.on('progress', ({ percent }) => { $('bar').style.width = percent + '%'; });
const blob = await offer.accept();
Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: offer.name }).click();
box.hidden = true;
};
});
</script>Step 5: Keep the History
Beam stores nothing. Three listeners write what a support desk wants to keep:
<?php
namespace App\Listeners;
use App\Models\Ticket;
use RealRashid\Beam\Events\CallDeclined;
use RealRashid\Beam\Events\CallEnded;
use RealRashid\Beam\Events\FileTransferred;
class RecordCallOnTicket
{
public function ended(CallEnded $e): void
{
Ticket::fromCallAbout($e->call->about)?->events()->create([
'actor_id' => $e->call->caller->id,
'type' => 'call',
'outcome' => $e->reason, // hangup | connection-lost
'seconds' => $e->duration, // measured on the server
]);
}
public function declined(CallDeclined $e): void
{
Ticket::fromCallAbout($e->call->about)?->events()->create([
'actor_id' => $e->call->caller->id,
'type' => 'call',
'outcome' => $e->reason, // declined | timeout
]);
}
public function transferred(FileTransferred $e): void
{
Ticket::open()->where('customer_id', $e->to->id)->latest()->first()?->events()->create([
'actor_id' => $e->from->id,
'type' => 'file',
'payload' => ['name' => $e->file->name, 'size' => $e->file->size],
]);
}
}// AppServiceProvider::boot()
Event::listen(CallEnded::class, [RecordCallOnTicket::class, 'ended']);
Event::listen(CallDeclined::class, [RecordCallOnTicket::class, 'declined']);
Event::listen(FileTransferred::class, [RecordCallOnTicket::class, 'transferred']);Ticket::fromCallAbout() reads the ticket id back out of the about line — preg_match('/^Ticket #(\d+):/', $about, $m) — which is why Step 2 formats it that way.
Step 6: Routes
use App\Http\Controllers\CallController;
Route::post('/tickets/{ticket}/call', [CallController::class, 'ring'])->name('tickets.call')->middleware('auth');
Route::get('/join/{call}', fn () => view('calls.join'))->name('beam.join')->middleware('auth'); // where a push notification's Answer landsHow It Works
- The agent presses Call customer. The gate says yes;
ring()mints two tokens and broadcastsIncomingCallon the customer's private channel. - If the customer has a tab open, the modal shows the ticket line and they answer; if not,
IncomingCallNotificationgoes out as a Web Push and Answer on it opens/join/{call}, whereBeam.answerFromNotification()joins. - The two browsers exchange offer, answer and candidates through your server and connect — directly, or through the relay when a direct path does not exist.
- Mute, camera and screen share are methods on the call; each fires an event, so the buttons follow the call, not the click.
- A file dropped on the call travels over the same connection and never touches the server;
FileTransferredfires when the receiver has verified it. - Either side hangs up;
CallEndedfires with the duration the server measured, and the listener writes it into the ticket. If nobody answers,CallDeclinedfires with reasontimeoutinstead.
That's it! You now have a support desk that calls its customers, and remembers.