Draft view and live reload
This page explains the two-window authoring workflow, in which an editor keeps the admin panel open in one browser window and the public site open in another, and does not use the Preview action to move between them. It covers which users see drafts, the draft banner, sharing a draft with a person who has no account, how the site window reloads itself, and the broadcasting setup that the websocket transport requires.
A user who is signed in to the CMS panel and browses the public site is served the newest draft of each page, with a banner at the top of the page stating that a draft is shown. Every other visitor is served the published page.
The site window also reloads itself when the page it shows is saved or published from another window, so an editor can save in the panel and see the result in the site window without reloading it by hand.
The draft view is enabled by default and needs no setup. The live reload works without setup by polling, and uses a websocket instead when broadcasting is configured.
What counts as a change
The site window reloads for two kinds of change:
- A change to the page. The page was saved or published in another window.
- A change to the site around the page. The skin, the footer or a menu changed. Every open site window reloads, not only a window showing the page that was edited.
Switching the skin, editing the footer and changing a menu are three of the changes that already flush the whole page cache. The reload applies the same rule: a change that invalidates every cached page has changed what every open window displays, so every open window is reloaded. Both actions are performed by FilamentCmsServiceProvider::siteChanged().
Who sees drafts
The package reuses Filament's panel access check instead of defining its own rule. DraftView::mayView() evaluates these conditions in order and returns false at the first one that fails:
cms.draft_view.enabledistrue.- A user is signed in, and the CMS panel can be resolved.
- The user implements
Filament\Models\Contracts\FilamentUserandcanAccessPanel()returnstruefor the CMS panel. Abilities::allows('content.view')returnstrue. A site that uses the package's permission layer can narrow access with thecontent.viewability; a site that does not passes this condition automatically.
A User model that does not implement FilamentUser is refused. Filament itself allows that case outside the production environment. For deciding who may read unpublished work, the package refuses it in every environment.
Abilities::allows() cannot perform this check on its own. It returns true whenever permissions are not enforced, which is the default, so on a multi-tenant application it would show a site's drafts to any signed-in customer account.
When the banner appears
The banner is shown only to a user who passes DraftView::mayView(), and only in these cases:
- The page has an unpublished draft. The banner reads “You are viewing a draft. This page has unpublished changes. Visitors see the published version.” It shows a Publish button and a More actions menu containing Edit this page, Share this draft and Show published.
- A shared layout region used by the page has an unpublished draft. The banner names the region and states that the change applies to every page using the template. Publish publishes the page draft and every reported region together.
- Drafts are switched off for this browser. The banner reads “Viewing the published site. Drafts are hidden.” Its More actions menu contains Edit this page and Show drafts.
Publish is shown only to a user with the content.publish ability. A page whose draft is identical to its published version shows no banner. A banner shown on every page stops being read, so the banner appears only when the page on screen differs from what visitors see.
The Show published / Show drafts switch is stored in the session, so it applies per browser: one browser can show drafts while another shows the published site. The banner cannot be dismissed and always carries the switch, because without a visible state an editor could not tell which version is on screen without publishing to find out.
The switch and the Publish button are POST forms, not links. Both change server state, and a GET request that changes state can be triggered by a browser prefetch without anybody clicking.
The banner never appears on the Preview route (cms.preview and cms.preview.block), for three reasons:
- A preview already shows one named version, chosen by the person who opened it.
- The banner is passed no version on that route, so it would describe the page as published.
- The switch changes a session value that the Preview route does not read, so the button would appear to do nothing.
All six layouts shipped with the package (article, default, docs, home, sidebar and wide) already include the banner, and so does the cms:make-layout stub. In a layout written by hand, add the component directly after the opening <body> tag. It renders nothing for a visitor who may not view drafts.
<body class="cms-body">
<x-cms::draft-banner :content="$cmsContent ?? null" :version="$cmsVersion ?? null" />
Sharing a draft
A draft can be sent to a person who has no account and cannot sign in.
The link is a temporary signed URL to the cms.preview route, carrying a content id and a version id. The route's middleware is ['web', 'signed'] and has no authentication guard, so the recipient needs no account and no panel access. The link expires after cms.preview.ttl_minutes, which defaults to 60.
Preview responses carry X-Robots-Tag: noindex, nofollow and Cache-Control: no-store, private. A search engine does not index a shared link, and a proxy or CDN in front of the application does not cache it.
From the panel
- In the admin panel, open the page's edit screen and choose Preview from the More actions menu. The preview opens in a slide-over.
- At the top of the slide-over, click the share icon (Share this draft).
- In the modal, copy the URL from the Link field. The modal states the expiry: “Anyone with this link can read the draft without signing in. It stops working after 60 minutes.”
The preview rows in the version history have the same share icon. A link created from a version row points to that version, not to the current draft.
From the site
- On the public site, signed in, open the page that has a draft.
- In the draft banner, open the More actions menu (the three-dot button) and click Share this draft.
The button hands over the link by the first method the browser supports: the system share sheet; the clipboard, after which the button reads “Link copied” for two seconds; or a window.prompt dialog containing the URL. The clipboard API is unavailable outside a secure context, which happens on a staging server served over plain HTTP. Dismissing the share sheet is treated as a decision by the user, so nothing is copied to the clipboard.
Share this draft is shown only when the page itself has a draft on screen. On the published view, the URL in the address bar is the link to share.
PreviewSigner::urlFor() memoizes one signed URL per content id, version id and lifetime for the duration of a request. The preview slide-over requests a URL twice, once for the iframe's src and once for the link the editor copies. Without the memo, two calls on either side of a one-second boundary would produce URLs with expiry times one second apart, and the shared link would differ from the previewed one.
How the reload decides to act
When the page shown in the site window is saved or published from another window, the site window reloads, regardless of the reader's scroll position. The browser restores the scroll position across a reload.
The reload script is rendered outside the banner markup in the template. A page whose draft matches its published version shows no banner, and that is the page an editor typically leaves open in the second window before making the first edit. If the listener were inside the banner, the first save would not reach that window.
The browser selects one of two transports itself:
- Broadcasting. When
window.Echoexists, the browser subscribes to two private channels:cms.content.{id}for the page, andcms.sitefor the skin, footer and menus. Both channels authorize withDraftView::mayView(), the same check the site applies before rendering a draft. - Polling. When
window.Echodoes not exist, the browser requests thecms.draft-view.stateroute everypoll_secondsseconds (2 by default).
The broadcast payloads contain a version id, a boolean and a timestamp, and no page content. The channel authorization is therefore a second safeguard, not the only one.
The site has its own channel because a skin change belongs to no particular page. A per-page channel could announce a skin change only by dispatching one event per page on the site.
The presence of window.Echo does not mean the socket is connected, and the script does not make its choice only once at page load. It binds to the Pusher connection's state_change event. When the state becomes unavailable, failed or disconnected (for example, a stopped Reverb service, a wrong port, or a laptop that went to sleep), polling starts. When the socket reconnects, polling stops and the script checks once immediately, because any save made during the outage was announced while the browser was not connected. The states initialized and connecting are ignored, because every new connection passes through them and polling on them would start a poll on every page load.
If Echo is present but its connector is not the Pusher-protocol connector that Reverb uses, the script cannot read the connection state. It trusts the socket and does not poll alongside it.
A poll compares values; it does not reload unconditionally. The state endpoint returns three values: the id of the version the user would currently be served, whether that version is a draft (is_draft), and a fingerprint of the site-wide settings. The script reloads only when one of them differs from the value it last recorded. If nothing is saved, polling continues indefinitely without reloading the page. The recorded values are seeded from the version rendered into the page, not from the first poll response, so a save made between rendering and the first poll is detected.
The site fingerprint is the newest updated_at timestamp across the site settings row, the menus table and the menu_items table. It makes polling detect the same site-wide changes that broadcasting announces. The broadcast payload carries the same fingerprint and the script records it, so a poll after a dropped socket does not reload a second time for the same change.
is_draft is compared because publishing the draft already on screen changes neither the version id nor the fingerprint: the same version becomes the published one. Before is_draft was compared, the banner stayed on screen after a publish from the panel, a version history row or the page header until the editor reloaded by hand. The banner's own Publish button submits a form and redirects, so it was not affected. The script compares the version id, then is_draft, then the fingerprint, and issues at most one reload per poll.
The poller does not send a request while the tab is hidden, and sends one as soon as the tab becomes visible again. It reloads the page on a 401, 403, 404 or 419 response: the endpoint returns 404 to any user who may not view drafts, which is the response a signed-out or expired session receives. The script is rendered only for a user who passes the draft-view check, so a visitor's browser runs none of it.
Broadcast failures do not prevent a save. ContentUpdatedForViewers and SiteUpdatedForViewers implement ShouldBroadcastNow, so they broadcast synchronously inside the write. If the broadcaster is unreachable, misconfigured or rejects the app id, the exception is caught and logged as a warning, and the save completes.
ContentUpdatedForViewers is not a domain event. It fires on both save and publish, carries no content, and is dispatched inside a try/catch, so a listener attached to it runs twice per save-and-publish and does not run at all when broadcasting throws. Listen to ContentSaved and ContentPublished for application logic.
Session expiry and the editing controls
A page that carries the poller also carries the front-end editing controls: an edit pencil on every block, a drag handle, a + button between blocks and a region tag on hover. When the session behind the page ends, all of those controls stop working.
The package therefore reloads the page when the session expires. The reloaded page has no session, DraftView::mayView() returns false, the banner and the reload script are not rendered, and the page shows the published version as a visitor sees it. Text typed into an open slide-over is lost when this reload happens; its save would have failed in any case, because the session had ended.
A private channel is authorized once, when it is subscribed, and never again, so a tab with a live socket is never told by the socket that its user signed out. Two mechanisms cover that case:
cms.draft_view.auth_check_seconds(default60;0disables it) requests the same state endpoint on a slow interval. Exactly one of the two loops runs at a time: the content poll detects an expired session as a side effect, and whenever the content poll is not running (because the socket is connected, orpollingisfalse), the session check runs instead. The cost is one request per minute per open editor tab.- A subscription error on either private channel reloads the page when the error carries status 401, 403 or 419.
/broadcasting/authreturns 403 to a signed-out user when a channel is subscribed: on the first subscription, or on a re-subscription after the socket dropped. A 500 from the authorization endpoint indicates a broken installation, not a signed-out editor, so it does not reload the page.
Inline edits made in the same window
Polling cannot distinguish a save made in another window from a save made in the current window. Every inline write (edit, delete, add or reorder) therefore ends by dispatching the Livewire event cms-draft-version with the id of the version it produced. The inline editor script re-dispatches it as a window event, and the banner script records it as the version on screen, so the next poll finds the id unchanged and does nothing. A save made by another user still reloads the page, because it produces a version id the banner script was not told about.
The first write that gives a published page a draft is the exception. That page was rendered with no banner, and the banner is server-rendered markup carrying a signed URL that the browser cannot produce. The script holds a hasBanner value; when it is false, the first announced version reloads the page instead of being recorded, which displays the banner with its Publish button and share link. Every later write is recorded silently.
hasBanner is set from the banner component's $show, not from $isDraft. The two differ when the draft view is switched off for the browser: the banner is shown and reads “Viewing the published site. Drafts are hidden.”, and keying on $isDraft would reload the page on every inline write to render an identical page.
A reorder never re-renders the page. The move returns the paths of the blocks whose positions changed, and the editor updates the data-cms-path attributes on the elements already in the DOM.
Identifying the active transport
Every step below happens in the site window, not the panel.
- In the site window, press F12 to open DevTools and click the Console tab.
- In the console, type
Object.keys(Echo.connector.channels)and press Enter. The result should listprivate-cms.content.<uuid>andprivate-cms.site.
To check the network traffic:
- In the site window, open DevTools, click the Network tab, and reload the page.
- In the Network tab, click the WS filter. Broadcasting is active if a connection to the Reverb host shows status 101. Open that connection's Messages sub-tab and look for
pusher_internal:subscription_succeededonprivate-cms.content.<uuid>and onprivate-cms.site. - In the Network tab, click the Fetch/XHR filter. Polling is active if a request to
cms-draft-view/<uuid>appears everypoll_seconds(2 seconds by default).
Normally only one of the two is present. If both are present, Echo loaded but the socket is down, and the polling fallback has taken over. Fixing the socket stops the polling without any other change.
Also in the Fetch/XHR list, one request to broadcasting/auth should appear directly after the page loads. Status 200 means the channel authorized the user. Status 403 means DraftView::mayView() refused: the user is signed in, but is not a user who may view drafts.
Configuration
These keys are in config/cms.php:
| Key | Default | Description |
|---|---|---|
| cms.draft_view.enabled | true | Shows the newest draft to a signed-in editor. false disables the whole feature, including the banner. |
| cms.draft_view.reload | true | Reloads the site window when the page it shows is saved elsewhere. false disables the reload and leaves the banner and the switch working. |
| cms.draft_view.polling | true | Allows the polling fallback to run. false relies on the websocket alone. |
| cms.draft_view.poll_seconds | 2 | Polling interval in seconds. Values below 1 are raised to 1. |
| cms.draft_view.auth_check_seconds | 60 | Interval in seconds of the session check that runs while the content poll is not running. 0 disables it. |
| cms.preview.ttl_minutes | 60 | Lifetime of a signed preview URL in minutes, including shared draft links. |
When reload is true, each rendered page runs three extra queries to read the site fingerprint (one each on site settings, menus and menu items). The queries are skipped for any user who may not view drafts, so a visitor's request runs none of them.
One-time setup: broadcasting in the application
Without this setup, the live reload uses the polling fallback, which is adequate for a small team.
- In a terminal at the site's root, run
php artisan install:broadcastingand choose Reverb when prompted. - In the site's
.envfile, delete any duplicateBROADCAST_CONNECTIONline and set theREVERB_*variables before running any other command. The warning below explains both. - In
resources/js/app.js, confirm that the file imports the Echo bootstrap:import './echo'; - In
config/cms.php, add the JavaScript entry to the top-levelvite.entrieskey, so the public site loads the built file:
'vite' => [
'entries' => ['resources/css/app.css', 'resources/js/app.js'],
],
- In a terminal at the site's root, run
npm run build, or leavenpm run devrunning. - In the same terminal, run
php artisan config:clear.
Keeping polling on after broadcasting works
Leave draft_view.polling set to true after Reverb is working. Polling is the fallback for a broken socket as well as for a site with no broadcasting:
- While the socket is connected, the banner script does not poll.
- When the socket's state becomes
unavailable,failedordisconnected, the script starts polling everypoll_secondsseconds. - When the socket reconnects, polling stops.
With polling set to false, a websocket outage leaves the banner showing that the page is a draft, but the page no longer reloads when the draft changes, and nothing on the page shows that live reload has stopped.
The polling cost is limited to signed-in editors: the state endpoint returns 404 to anyone who may not view drafts, the loop does not run while the tab is hidden, and a 401, 403, 404 or 419 response reloads the page instead of polling again. A tab whose socket never connected, such as a tab opened before broadcasting was configured, polls for as long as it stays open; reload the tab to connect it to the socket.
Set polling to false only on a site that must make no state requests at all, and accept that live reload then depends entirely on the socket.
Reverb locally under Herd
Laravel Herd runs one shared Reverb server for every site on the machine, with fixed credentials of its own. An app id that does not match Herd's is rejected with No matching application for ID.
- In a terminal, read Herd's Reverb values:
cat ~/.config/herd/bin/services/reverb/1.x/.env - In the site's
.envfile, setREVERB_APP_ID,REVERB_APP_KEYandREVERB_APP_SECRETto exactly those values. At the time of writing they are1001,laravel-herdandsecret. - In the same file, set
REVERB_HOST=localhost,REVERB_PORT=8080andREVERB_SCHEME=http. - In a terminal at the site's root, run
php artisan config:clear.
Because the Reverb server is shared, channel names are not isolated between local projects. This applies to local development only.
Reverb on Laravel Cloud
- In the Laravel Cloud dashboard, open the application and add a Reverb instance.
- In the same dashboard, under Environment, set
BROADCAST_CONNECTION,REVERB_APP_ID,REVERB_APP_KEY,REVERB_APP_SECRET,REVERB_HOST,REVERB_PORTandREVERB_SCHEMEto the values the Reverb instance shows. - In the same dashboard, redeploy the application.
The application has its own Reverb instance on Laravel Cloud, so the shared-server limitation described for Herd does not apply. Without this setup, the live reload falls back to polling and continues to work.