Stage helpers
Backstage ships a small set of runtime helpers, exposed through stage(), that give you the current site, content and language inside any request, plus a few Blade directives for conditional rendering. They live in packages/core and are always available — no installation step required.
Looking for the front-end
props()helper instead? See Props helpers.
Request bindings
For every request that resolves to a piece of content, the BindSite middleware (registered automatically on the web group) populates three container bindings:
content— the currentBackstage\Models\Contentinstancesite— the relatedBackstage\Models\Sitelanguage— the related language
The bindings are registered with app()->scoped(...), so they are reset between requests under Octane and other long-lived workers.
stage()
The stage() helper is the main entry point. Without arguments it returns the helpers instance:
stage(); // Backstage\Helpers\Support\Helpers
stage()->site(); // current Site
stage()->content(); // current ContentYou can also resolve a binding directly by name, with optional dot-notation for properties:
stage('site'); // current Site
stage('content.title'); // shorthand for stage()->content()->title
stage('language'); // current LanguageIf the binding is not registered (for example, on a request that did not resolve content), stage('site') returns null. Calling stage()->site() without arguments outside a content response throws an exception.
Looking up a specific record
Both site() and content() accept lookup arguments:
stage()->site('main'); // Site where slug = 'main'
stage()->site($id, 'ulid'); // Site where ulid = $id
stage()->content('about'); // Content where slug = 'about' (current locale)
stage()->content('about', locale: 'nl'); // Content where slug = 'about' and language_code = 'nl'
// Scope a content lookup to a specific site
stage()->content('about', site: 'main'); // by site slug
stage()->content('about', site: $site, siteColumn: 'id'); // by Site instance + columnWhen $slug is null, the call returns the request-bound record (equivalent to app('site') / app('content')).
Menus
stage()->menu() resolves a menu by its slug ($name), scoped to the current site and locale, and returns it as a nested array tree — plain data, not Eloquent models — ready to render in a Blade view:
stage()->menu(); // 'main' menu for the current site + locale
stage()->menu('footer'); // a named menu
stage()->menu('main', $site, 'nl'); // explicit site + localeThe menu is matched on menus.slug = $name, then narrowed to the resolved site_ulid and language_code (both columns live on the menus table). So a main menu can exist per site/locale without the slug having to encode them — and if no menu matches that slug for the current site and locale, the helper returns the empty shape described below.
Each node is an array:
[
'name' => 'Product',
'url' => '#', // resolved link: linked content URL, else the manual URL, else '#'
'target' => null,
'is_action' => false, // target starts with 'action'
'is_primary' => false, // target === 'action-primary'
'children' => [ /* same shape, nested to any depth */ ],
]Items are loaded through the adjacency-list tree, ordered by position. Any linked content is eager-loaded with only the columns needed to build its URL, so resolving a menu is just the slug lookup plus one tree query.
Diverging navigation and actions
By default ($diverge = true) the top-level items are split into navigation links and action buttons — actions being items whose target starts with action. A component can destructure them in one call:
// app/View/Components/Section/Navigation.php
[$this->navigation, $this->actions] = stage()->menu('main');Pass diverge: false to get a single flat collection of root nodes instead — useful for a footer that has no actions:
$this->footerMenu = stage()->menu('footer', diverge: false);When no site can be resolved, the helper returns the matching empty shape ([collect(), collect()] when diverging, otherwise an empty collection), so the destructure never fails.
Rendering then just walks the arrays:
@foreach($navigation as $item)
@if(count($item['children']))
<button type="button">{{ $item['name'] }}</button>
<ul>
@foreach($item['children'] as $child)
<li><a href="{{ $child['url'] }}">{{ $child['name'] }}</a></li>
@endforeach
</ul>
@else
<a href="{{ $item['url'] }}">{{ $item['name'] }}</a>
@endif
@endforeachThe
activestate is deliberately not part of the array — it depends on the current request, so resolve it in the view layer (e.g. anisActive()method on the component).
Breadcrumbs
stage()->breadcrumbs() returns the trail from a site's home page down to a piece of content, as plain arrays ready to render:
stage()->breadcrumbs(); // the request-bound content
stage()->breadcrumbs($content); // a specific Content
stage()->breadcrumbs($content, 'de'); // in an explicit language
stage()->breadcrumbs($content, includeHome: false); // without the home crumbEvery row has the same two keys, and both are always present:
[
['name' => 'Home', 'url' => 'https://example.com/nl'],
['name' => 'Knowledge base', 'url' => 'https://example.com/nl/knowledge-base'],
['name' => 'Arthritis', 'url' => null], // no public address
]url is null for a page that has no public address, so a view can choose between a link and plain text without first checking that the key exists:
@foreach(stage()->breadcrumbs() as $crumb)
@if($crumb['url'])
<a href="{{ $crumb['url'] }}">{{ $crumb['name'] }}</a>
@else
<span>{{ $crumb['name'] }}</span>
@endif
@endforeachHow the trail is built
The trail is the home page, then the content's ancestors from the root downwards, then the content itself. Ancestors come from the content tree, so nesting of any depth works.
Ancestors that are not public stay in the trail, with url set to null. A breadcrumb describes a hierarchy, so leaving a level out would put a page directly under its grandparent and misrepresent where it sits. Rendering it as text rather than a link is what the url check in the example above is for.
The content appears only once, so the trail for a home page is a single crumb instead of Home > Home.
Passing includeHome: false removes the home page wherever it sits in the trail, the rendered page included. On the home page itself the result is therefore an empty array, not a single leftover crumb.
Language
The language is taken from the content itself, not from app()->getLocale(). Those two can differ, most visibly on an error response, where the locale may still be the request default while the page being rendered belongs to another language. Passing $locale overrides both.
Home and ancestors are matched in that same language and scoped to the content's own site, so an install with several sites never mixes trails.
Finding the home page
Home is resolved in two steps, both limited to public content and scoped to the content's site and language:
- a page whose slug is
home - otherwise the single public page whose path is
/
The second step is deliberately strict. Types that are not routable share the / path, so when more than one public page claims it the helper returns no home crumb at all, rather than linking home to an arbitrary record.
Failure behaviour
Breadcrumbs are decorative, so the helper never throws. Each missing piece shortens the trail instead:
| Situation | Result |
|---|---|
| no content bound and none passed | [] |
| no home page in that language | trail without the home crumb |
| a URL cannot be composed | that row keeps 'url' => null |
| ancestors cannot be resolved | trail without ancestors |
Degrading is not the same as staying quiet. Like stage()->fieldOptions(), the last three go through Laravel's report() handler, so the shorter trail is visible in your logs with a full stack trace. A breadcrumb that silently loses a level looks identical to a page that genuinely has no parent, which is how a swallowed LazyLoadingViolationException can hide for a long time.
Ancestors are loaded with loadMissing() for that reason. Reading the relation implicitly would trip preventLazyLoading under Model::shouldBeStrict(), but only for content hydrated from a query that returned more than one row (see Builder::hydrate()), so the failure would appear in bulk contexts and nowhere else.
Unlike stage()->content(), calling it outside a content response is safe: it reads the content binding only when that binding exists, instead of throwing. That matters on error responses, where an error page is bound only when one exists for the current site and language, and the same layout renders either way.
Field options
stage()->fieldOptions() returns the options array configured on a field, looked up by its model_key and slug. It replaces the brittle chain of fetching the field and reaching into config['options'] by hand:
// Before — explodes if the field is missing or has no options
$statusOptions = Field::where('model_key', 'property')->where('slug', 'status')->first()->config['options'];
// After
$statusOptions = stage()->fieldOptions('property', 'status');The helper never throws. If no field matches the given model_key + slug, or the matched field has no options configured, it reports the problem through Laravel's report() handler (a RuntimeException, so you get a full stack trace in your logs) and returns an empty array:
stage()->fieldOptions('property', 'status'); // ['draft' => 'Draft', 'published' => 'Published', ...]
stage()->fieldOptions('property', 'missing'); // [] — exception reported, request continuesBecause the fallback is always [], it is safe to feed straight into a Blade loop or a select without guarding the call.
Blade directives
@site
Render a block only when the current site has a given slug:
@site('main')
<p>Welcome to the main site.</p>
@endsite@content
Render a block only when the current content has a given slug:
@content('home')
<h1>{{ backstage()->site()->name }}</h1>
@endcontentBoth directives compile to a check against hasSlug(...) on the bound model, so they only work inside requests where BindSite has populated the container.
Examples
{{-- Show a hero only on the homepage of the main site --}}
@site('main')
@content('home')
<x-hero :title="stage()->content()->title" />
@endcontent
@endsite
{{-- Pull a sibling page by slug --}}
@php($about = stage()->content('about'))
<a href="{{ $about->url }}">{{ $about->title }}</a>// In a controller
public function show()
{
$site = stage()->site();
$content = stage()->content();
return view('page', compact('site', 'content'));
}