Drafts, sharing and live reload
This document 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 on the public site, 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. It is written for the developer who configures the workflow and for the developer who diagnoses it afterwards.
Overview
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. This lets an editor save in the panel and see the result in the site window without switching to it and reloading by hand.
Three site-wide changes also reload every open site window, not only a window showing the page that was edited:
- switching the skin
- editing the footer
- changing a menu
These are three of the changes that already flush the whole page cache. The package applies one rule to both: 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(). See Caching.
The draft view is enabled by default and needs no setup. The live reload works without any setup by polling, and uses a websocket instead when broadcasting is configured.
Who sees drafts
The package does not define its own rule for who may see drafts. It reuses Filament's panel access check. DraftView::mayView() (in src/Support/DraftView.php) 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 further with thecontent.viewability. A site that does not use the permission layer 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. On a multi-tenant application, relying on it alone would show a site's drafts to any signed-in customer account.
Why a draft is never cached for other visitors
The CachesCmsPages middleware bypasses the shared page cache for every authenticated request: it renders the page and marks the response X-Cms-Cache: BYPASS without storing it. A page rendered from a draft is therefore never written to the cache and never served to another visitor.
This behavior existed before the draft view was added, and the draft view depends on it for its safety. The same behavior allows the banner to render a signed preview URL into the page, because the page that carries the URL is never stored.
The draft banner
The banner is rendered by the <x-cms::draft-banner> component (src/View/Components/DraftBanner.php and resources/views/components/draft-banner.blade.php). It is shown only to a user who passes DraftView::mayView(), and only in these cases:
| Case | Banner text | Controls |
|---|---|---|
| The page has an unpublished draft | "You are viewing a draft." followed by "This page has unpublished changes. Visitors see the published version." | Publish, and the 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 | "You are viewing a draft." followed by a sentence naming the region, which states that the change applies to every page using the template | Publish, which publishes the page draft and every reported region together, and the More actions menu |
| Drafts are switched off for this browser | "Viewing the published site. Drafts are hidden." | The More actions menu containing 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 it always carries the switch. 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). There are 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.
Adding the banner to a custom layout
All six layouts shipped with the package (article, default, docs, home, sidebar and wide in resources/views/layouts/) already include the banner, and so does the stub that cms:make-layout uses (stubs/layout.stub). See Layouts, regions and page width.
A layout written by hand needs the component added to it:
- In the layout's Blade file, add the component directly after the opening
<body>tag:
<body class="cms-body">
<x-cms::draft-banner :content="$cmsContent ?? null" :version="$cmsVersion ?? null" />
The component renders nothing for a visitor who may not view drafts.
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'] (routes/cms.php) 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 (CmsPreviewController). A search engine does not index a shared link, and a proxy or CDN in front of the application does not cache it.
A share link can be created in two places.
From the admin 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 number is
cms.preview.ttl_minutes).
The preview rows in the page's 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 public 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 (
navigator.share) - the clipboard (
navigator.clipboard), after which the button reads "Link copied" for two seconds - a
window.promptdialog containing the URL
The clipboard API is unavailable outside a secure context, which happens on a staging server served over plain HTTP. The prompt is the fallback for that case, so the button always produces the URL in some form.
Dismissing the share sheet is treated as a decision by the user, not as a failure. The script does not fall back to the clipboard in that case (AbortError), because that would place a URL on the clipboard that the user did not ask for.
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 already is the link to share. A draft that exists only in a shared layout region does not produce a share link, because the signed URL addresses one content version.
One signature per request
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 link the editor shares would differ from the link shown in the preview.
Live reload
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.
An earlier version reloaded only when the reader was near the top of the page, and further down offered a chip to click instead. That behavior assumed a visitor reading the page. The reload script is never rendered for a visitor, only for a user who may view drafts, and in practice that user is the editor who has just published in the other window and is waiting for the result. The chip was read as the feature not working, and editors worked around it by scrolling up and publishing a second time. The chip was removed.
The reload script is rendered outside the banner markup in the template (@if ($reload && $contentId) in draft-banner.blade.php). 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 two transports
The browser selects the transport itself:
| Transport | Selected when | Channels or endpoint |
|---|---|---|
| Broadcasting | window.Echo exists | Private channel cms.content.{id} for the page, and private channel cms.site for the skin, footer and menus |
| Polling | window.Echo does not exist | GET the cms.draft-view.state route (/cms-draft-view/{content}) every cms.draftview.pollseconds seconds |
Both channels authorize with DraftView::mayView() (FilamentCmsServiceProvider), which is the same check the site applies before rendering a draft. 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, and a window showing page A must reload for a skin change even though page A was not edited.
When the script checks for Echo
The script checks whether window.Echo exists after the DOMContentLoaded event.
The reload script is a classic inline script in the page body, so it runs while the browser is still parsing the document. Echo is loaded from a Vite module, and module scripts are deferred until parsing finishes. If the script checked before DOMContentLoaded, window.Echo would be undefined on every site that has Echo. A correctly configured site would then poll indefinitely as if it had no broadcasting, and with polling switched off it would never reload.
After DOMContentLoaded, the script waits up to three seconds more, checking every 100 ms, for a site that creates Echo asynchronously (through a dynamic import, or a script of its own that runs later). If Echo has not appeared after three seconds, the script falls back to polling.
Monitoring the socket connection
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.
The script binds to the Pusher connection's state_change event:
- When the state becomes
unavailable,failedordisconnected(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
initializedandconnectingare ignored. Every new connection passes through them, so polling on them would start a poll on every page load.
Without this monitoring, a broken socket would leave the page listening to nothing. That outcome is worse than having no broadcasting configured, because with no broadcasting the polling fallback runs, and with a broken socket it would not.
If Echo is present but its connector is not the Pusher-protocol connector that Reverb uses, the script cannot read the connection state. In that case it trusts the socket and does not poll alongside it.
What a poll compares
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 these 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. A save made between rendering and the first poll is therefore detected, and polling can begin at any later time without missing a change.
The site fingerprint is the newest updated_at timestamp across the site settings row, the menus table and the menu_items table (SiteFingerprint::current()). It makes polling detect the same site-wide changes that broadcasting announces. The broadcast payload on cms.site carries the same fingerprint, and the script records it, so a poll that runs after a dropped socket does not detect the same change a second time and reload again.
The is_draft value is compared because publishing moves a pointer rather than writing a new version. Publishing the draft that is already on screen leaves the version id unchanged (the same version becomes the published one) and leaves the site fingerprint unchanged. Before is_draft was compared, the banner stayed on screen over a page with nothing outstanding until the editor reloaded by hand, which looked as though the publish had failed. The endpoint always returned is_draft; the page now seeds it from what it rendered and reloads when it changes.
This case occurs only when the page is published from somewhere other than the banner: from the panel's edit screen, from a version history row, or from the page header. The banner's own Publish button submits a form and redirects, so it reloads the page in any case.
The script compares the version id first, then is_draft, then the site fingerprint, and issues at most one reload per poll. When more than one value has changed, a single reload displays all of the changes.
The poller also behaves as follows:
- It does not send a request while the tab is hidden, and sends one immediately when the tab becomes visible again, without waiting for the rest of the interval.
- It reloads the page when the endpoint returns 401, 403, 404 or 419. The endpoint returns 404 to any user who may not view drafts, which is the response a signed-out or expired session receives.
Session expiry and the editing controls
The reload script, the banner and the front-end editing controls are rendered only for a user who may view drafts. A page that carries the poller therefore also carries 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, and without a check the editor would discover it only by clicking one and seeing it fail with no explanation on screen.
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. No second reload can follow, because the reloaded page does not poll.
Text typed into an open slide-over is lost when this reload happens. The save would have failed in any case, because the session had ended, and the reload happens before the editor has written more. The loss is still real.
Detecting a sign-out while the socket is connected
A private channel is authorized once, when it is subscribed, and never again. Signing out does not affect an established subscription. A tab with a live socket is therefore told about every content change and is never told that the user is no longer signed in.
Two mechanisms cover that case:
cms.draft_view.auth_check_seconds(default60;0disables it) requests the same state endpoint on a slow interval. It is the only loop that runs while the socket is connected. Exactly one of the two loops runs at a time: the content poll (everypoll_seconds, default 2) detects an expired session as a side effect, and whenever the content poll is not running (because the socket is connected, orcms.draft_view.pollingisfalse), 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, and that response arrives when a channel is subscribed: on the first subscription, or on a re-subscription after the socket dropped. Other statuses do not reload. A 500 from the authorization endpoint indicates a broken installation, not a signed-out editor, and reloading on it would repeat indefinitely.
Inline edits made in the same window
Polling detects saves made in another window, and it cannot distinguish them from a save made in the current window. Before this was handled, every save from the front-end inline editor came back as a reload two seconds later: the edited block had already been replaced in place, and then the whole page reloaded under the editor who made the edit.
Every inline write (edit, delete, add or reorder) ends by dispatching the Livewire event cms-draft-version with the id of the version it produced (InlineBlockEditor, src/Livewire/InlineBlockEditor.php). The inline editor script re-dispatches it as a window event (resources/views/components/inline-editor.blade.php:931), and the banner script records it as the version currently on screen (resources/views/components/draft-banner.blade.php:313). The next poll compares against that id, finds no change, 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 nothing outstanding, so it has no banner: nothing states that the page is now ahead of what visitors see, and there is no Publish button or share link. The banner is server-rendered markup that carries a signed URL, so the browser cannot add it by itself. When the version was recorded silently, the editor saw unpublished changes with nothing on screen saying so.
The script therefore holds a hasBanner value. When it is false, the first announced version reloads the page instead of being recorded. This costs one reload per page, on the first edit. Every later write is recorded silently, because the banner is then on screen.
hasBanner is set from the 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 a new draft does not change that text. Keying on $isDraft would reload the page on every inline write to render an identical page.
A reorder also 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, so only the dragged block moves on screen.
Broadcast failures and saving
ContentUpdatedForViewers and SiteUpdatedForViewers both 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.
A failed broadcast costs only the automatic reload. If the exception were not caught, an editor would lose a page save, or a settings screen would refuse to save, because the websocket server rejected the connection.
ContentUpdatedForViewers is not a domain event. It fires on both save and publish, carries no content, and is dispatched inside a try/catch. 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. See Integrating another plugin.
Runbook: identifying the active transport
Every step below happens in the site window, not the panel.
Checking the channel subscriptions
- 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 list
private-cms.content.<uuid>andprivate-cms.site.
This checks which channels the browser *subscribed to*, not whether window.Echo exists. By the time a console can be opened, Echo exists whether or not the reload script used it, so its presence proves only that the site loads Echo. An empty channel list with a healthy socket means the script decided there was no broadcasting and fell back to polling.
Checking the network traffic
- In the site window, open DevTools and click the Network tab.
- 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.
Runbook: one-time broadcasting setup
Without this setup, the live reload uses the polling fallback. Polling is adequate for a small team.
Steps
- In a terminal at the site's root, run
php artisan install:broadcastingand choose Reverb when prompted. - In the site's
.envfile, check for a duplicateBROADCAST_CONNECTIONline and delete the extra one, and set theREVERB_*variables before running any other command. Both reasons are explained below the steps. - 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.
Two problems caused by install:broadcasting
- **
install:broadcastingappendsBROADCAST_CONNECTIONto.enveven when the key already exists. phpdotenv keeps the first** occurrence of a key, so the original line takes precedence over the value the command just wrote, and no error is shown. - **The command also publishes
routes/channels.php, which constructs the broadcaster at boot. Until theREVERB_*variables exist, every** Artisan command throws an exception, including the commands used to diagnose the problem.
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.
Runbook: Reverb locally under Herd
Laravel Herd runs one shared Reverb server for every site on the machine, with fixed credentials of its own. These credentials cannot be chosen. An app id that does not match Herd's is rejected with No matching application for ID.
Steps
- 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.
Runbook: Reverb on Laravel Cloud
Steps
- 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.
Configuration
These keys are in config/cms.php:
| Key | Default | Description |
|---|---|---|
| draftview.enabled | true | Shows the newest draft to a signed-in editor. false disables the whole feature, including the banner |
| draftview.reload | true | Reloads the site window when the page it shows is saved elsewhere. false disables the reload |
| draftview.polling | true | Allows the polling fallback to run. false relies on the websocket alone |
| draftview.pollseconds | 2 | Polling interval in seconds. Values below 1 are raised to 1 |
| draftview.authcheckseconds | 60 | Interval in seconds of the session check that runs while the socket is connected. 0 disables it |
| preview.ttlminutes | 60 | Lifetime of a signed preview URL, in minutes |
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 whether reload is on or off.