← All writing

Every story at its own pace

Highly performant content consumption and rendering with Laravel, across sophisticated CMS-driven workloads.

A finished walk between two hillforts might never change again. A Photo365 project could grow every day. Asking both for updates every few minutes would be a strange use of effort.

I wanted to explore how Laravel handles sophisticated CMS-driven experiences: consuming content efficiently, preparing it once, and rendering it in ways that feel quick and engaging. The person looking after the content should also have clear control over when it changes.

A CMS with a particular purpose

I built Storyus as a place to gather and tell stories. Photographs, words, dates and places become timelines, albums and journeys. A Journey can use location data already in the photographs to bring the geography into the story.

In this integration, Storyus is a specialised content management system. I edit there, and this Laravel site reads its public JSON to create its own experience. Owning both ends gives me room to experiment, but the same approach to content delivery could work with another CMS too.

Keep the work out of the reading path

Laravel collects and validates the source content, prepares the fields each presentation needs, and saves a JSON snapshot in private object storage. A fast cache, backed by Valkey in production, keeps the frequently read copy close to the application.

Two paths: editors or scheduled tasks refresh Storyus content into private storage and invalidate its cache. Visitors read through Laravel's cache, with storage as the fallback, then explore the page in Inertia and React.

The distinction matters: cache lifetime is not content freshness. Our saved story has no automatic expiry. Its fast copy lasts an hour. When that cache expires, Laravel reads the saved snapshot again, without asking Storyus to rebuild it.

For managed content, collection is an explicit step before publication. Visitors and navigation prefetch don't trigger that work. Inertia delivers the page and its prepared data; React handles the interaction. We're regenerating the data behind the page, rather than storing a generated HTML page.

The controller's simplified shape looks like this:

return Inertia::render('Panorama', [
    'title' => $fields['title'],
    'panorama' => Inertia::defer(
        fn () => $storyus->published($resource, 'panorama')
    ),
]);

Here, published() is our saved-content reader. The full handler also guards prefetch requests. Deferring the data lets the page shell appear first, with space reserved for the view while its content arrives.

That makes a CMS outage less likely to become a reading interruption, and makes the source-request cost independent of how many people visit.

Let the editor choose the rhythm

The private Manage area offers Only when I ask, scheduled intervals from hourly to every 30 days, and Refresh now. A completed journey can stay on manual refresh. A growing album can be checked daily.

An illustrative week: a finished journey refreshes once when requested; a growing story is checked daily, with two changed versions. Visitor reads are independent of both schedules.

Laravel Cloud's Scheduler runs the due-work command hourly when enabled:

php artisan storyus:refresh --due

Only resources whose selected interval has elapsed are checked. An editor doesn't need the command line, but developers can also refresh a selected group:

php artisan storyus:refresh --tag=album

The tags select resources for regeneration. They don't clear the whole site's cache.

Adding a Storyus reference, previewing its presentation, publishing its details and placing its card on the homepage or Experiments page are separate editorial choices. Those choices survive application deployments. Drafts stay behind authentication, and revision checks protect against one editor silently overwriting another's changes.

Invalidation is part of publishing

During a refresh, visitors can keep reading the previous saved copy. Laravel validates the replacement, saves it durably, then invalidates the relevant cached record. The next read uses the new version. Already-open pages aren't pushed an update.

A temporary source failure keeps the last good content. An explicit private, removed or denied response withdraws it. With a manual-only policy, changing visibility in Storyus also needs a refresh here.

Production is the authority for published stories and card arrangements. The local reader can use that published projection through read-only endpoints, revalidating on demand after a 60-second cache interval. Local experiments stay in a separate draft workspace. This keeps development previews useful without making local editing a route into production storage.

When publishing becomes the trigger

The next workflow is event-driven. This part is proposed, not yet implemented: publishing or updating a story in the CMS would send a signed webhook to Laravel. An editor could publish once, then see that update arrive here without pressing a second refresh button or waiting for the next scheduled check.

Proposed workflow: an editor publishes in the CMS; a signed event reaches Laravel, which verifies it and queues a targeted refresh. The worker retrieves the authoritative source, saves valid content and invalidates its cache. The next visitor read receives it. Draft publication and promotion remain separate.

The webhook would identify the changed resource, rather than supply trusted page content. Laravel would verify the signature and timestamp, reject replayed events, and acknowledge accepted work quickly. A worker would then fetch the current source through the same validation and refresh path we already use. Duplicate events could share one refresh; fetching the current source would avoid applying an older event's content over a newer version.

For a story already published here, a successful refresh would make the new content available on the next read. A source withdrawal would remove it. A connection failure would preserve the last good copy and be retried, with the pending or failed state visible to the editor. Publishing a new local page and choosing where to promote it would remain deliberate actions in Manage.

Manual refresh would still be useful for recovery, and scheduled checks could provide a backstop where appropriate. The event changes when regeneration starts, not how the visitor's request is served.

Performance includes what we don't load

A photograph doesn't need a panorama engine. Our Pannellum viewer and its stylesheet load only after someone chooses Look around. The larger panorama image waits until then too. The rest of the site has no reason to download them.

In the local production build, that deferred viewer is about 18.8 KB of gzipped JavaScript and 2.4 KB of CSS. Desktop and mobile browser checks confirm neither the viewer nor the larger panorama image is requested before activation. Those are bundle and loading measurements, not global response-time results.

The next useful evidence is measurement: warm cache reads, saved-copy fallbacks, refresh duration, and the bytes needed for each experience. For global response times, I want repeated measurements from named regions, with dates, cache conditions and median and slower responses shown separately. Network time, Laravel processing and image delivery are different things. A fast application cache alone doesn't mean the content is replicated around the world.

Those regional measurements are still to come. The aim is an engaging CMS-driven experience that reuses the work behind each page, loads interaction when it is needed, and lets publishing decisions control freshness.

More from the notebook