Skip to content

[6.x] Include Tag - #15182

Open
JohnathonKoster wants to merge 4 commits into
statamic:6.xfrom
JohnathonKoster:feat/antlers-include-tag
Open

[6.x] Include Tag#15182
JohnathonKoster wants to merge 4 commits into
statamic:6.xfrom
JohnathonKoster:feat/antlers-include-tag

Conversation

@JohnathonKoster

@JohnathonKoster JohnathonKoster commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #8175
Fixes #10703
Fixes #11486
Fixes #12709

Overview

This PR adds a new include tag: a strictly-scoped alternative to partial for rendering another view, available in both Antlers and Blade.

The partial tag automatically shares every variable from the template using it with the partial being rendered. That convenience is the root cause of a long line of historical scoping issues and little paper-cuts. Variables set inside a partial leaking back out (but only sometimes), parameters and front matter showing up in other partials rendered later on the page, and behavior changing depending on which syntax was used.

Fixing these issues with the existing partial tag would absolutely break a ton of sites, so include is here!

<s-include:cards/author name="Jimothy" :bio="author_bio" />
<s:include:cards/author name="Jimothy" :bio="$authorBio" />

How it differs from partial

partial include
Variables from the surrounding template All of them Only what you pass in
Variables set inside the view Can leak back into the page Stay inside the include
The Cascade (page, globals, etc.) Automatically available Requires cascade="true"
Front matter Visible to other views rendered later Stays with the include
Slots Rendered up front, passed as strings Rendered on demand, and the view can pass data to them

Passing data

You must pass data to the include tag explicitly. Parameters become variables inside the view, and you can spread an entire array using :params. Inside the view, use params to check what was passed in:

<s-include:cards/author :params="author" role="Editor" />

{{# Inside the view: {{ name }}, {{ avatar }}, {{ role }}, {{ params:role }} ... #}}

Use handle_prefix to make prefixed keys like hero_title available as both hero_title and title:

<s-include:hero :params="entry" handle_prefix="hero_" />

Slots

Content between the tag pair becomes the default slot, and you can define named slots with slot:name pairs.

<s-include:modal title="Delete this entry?">
    <s-slot:footer><button>Cancel</button></s-slot:footer>
    <p>This action cannot be undone.</p>
</s-include:modal>
{{# views/modal.antlers.html #}}
<h2>{{ title }}</h2>
<main>{{ slot }}</main>
<footer>{{ slot:footer }}</footer>

Slots only render when the view actually uses them, and the view can pass data back to your slot content. A view can render a slot once per item in a loop, for example:

{{# views/table.antlers.html #}}
<table>{{ rows }}<tr>{{ slot:row :cell="value" }}</tr>{{ /rows }}</table>

{{# Your template: #}}
<s-include:table :rows="rows">
    <s-slot:row><td>{{ cell }}</td></s-slot:row>
</s-include:table>

You can also forward a slot you received on to another include:

{{# views/panel.antlers.html #}}
<section class="panel">{{ slot }}</section>

{{# views/card.antlers.html forwards its slot along: #}}
<s-include:panel :slot="slot" />

{{# Your template: #}}
<s-include:card>Card content</s-include:card>

The Cascade

Included views don't see the Cascade by default. Pass cascade="true" when you want it:

<s-include:site_header cascade="true" />

Conditionals and existence

Use when and unless to control whether anything renders. exists and if_exists work the same way they do on partial:

<s-include:promo :when="show_promo" />
<s-include:if_exists src="cards/{type}" />

The issues

Notes for reviewers

  • When a partial uses handle_prefix, the prefix rewrites variable lookups in everything rendered inside it. The include tag suspends this while it renders, so an enclosing partial's prefix never reaches the included view. Components rendered inside such a partial still inherit the prefix. That is probably unintentional, but changing it could break existing sites, so it's left alone for now.

@JohnathonKoster
JohnathonKoster marked this pull request as draft August 13, 2026 07:31
@JohnathonKoster
JohnathonKoster marked this pull request as ready for review August 13, 2026 17:29
@jackmcdade

Copy link
Copy Markdown
Member

Looking forward to using this! After a review and playing around, got a couple things I want to make sure are intentional:

Blade → Antlers cascade — If I do <s:include:some_antlers_view /> from Blade, it still sees Cascade values even without cascade="true". Antlers → Antlers correctly gets nothing. Feels like isolation only kicks in on the Antlers tag path — is that working as intended?

scope punching out — Using scope inside an include writes straight to Cascade, and you can even smuggle a deferred slot out and render it later. Is that a blessed escape hatch we should call out, or something we want to plug?

if_exists body in Blade — exists uses the tag body as conditional output, but if_exists turns it into a slot. That feel right to you?

@JohnathonKoster

Copy link
Copy Markdown
Contributor Author

Blade → Antlers cascade — If I do <s:include:some_antlers_view /> from Blade, it still sees Cascade values even without cascade="true". Antlers → Antlers correctly gets nothing. Feels like isolation only kicks in on the Antlers tag path — is that working as intended?

Refactored a few things so the include tag will isolate the Cascade in Blade. This logic was moved into the include tag itself and out of the processor to not accidentally mess with existing behaviors.

scope punching out — Using scope inside an include writes straight to Cascade, and you can even smuggle a deferred slot out and render it later. Is that a blessed escape hatch we should call out, or something we want to plug?

Added a test to codify it. It's a neat use of existing behaviors and I don't see the harm in it; it'd be a lot more work to shut this down and its kind of cool!

if_exists body in Blade — exists uses the tag body as conditional output, but if_exists turns it into a slot. That feel right to you?

The existing behavior is correct IMO. The exists variant shouldn't be used as a tag (or tag pair) as its purpose is to determine if a view exists:

@if (Statamic::tag('include:exists')->src('cards/author')->fetch())

@endif

@if (Statamic::tag('partial:exists')->src('cards/author')->fetch())

@endif
{{ if {include:exists src="cards/author"} }} ... {{ /if }}
{{ if {partial:exists src="cards/author"} }} ... {{ /if }}

ref: https://statamic.dev/tags/partial-exists

@jackmcdade

Copy link
Copy Markdown
Member

Awesome, thanks man!

@jasonvarga jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice piece of work — the scoping model is the right call, the try/finally discipline around cascade and prefix state is careful, and the test suite (9 files, ~1,680 lines, one test per closed issue plus a dedicated SandboxTest for scope-escape attempts) is unusually thorough. The randomized nowdoc terminator is a good catch on its own: slot content containing a literal COMPILED; line would previously have broken out of the hoisted heredoc.

A couple of things need fixing before this ships, since once the behaviour is out it becomes BC. Details are on the relevant lines; the rest are notes below.

Notes (not line-specific)

Missing Antlers coverage for include:exists / include:if_exists. The PR description documents <s-include:if_exists src="cards/{type}" />, and RendersViews::exists() / ifExists() are new code paths for IncludeTag. Blade covers them (IncludeCompilerTest::it_forwards_exists_method_calls and it_forwards_if_exists_method_calls), but there's nothing equivalent under tests/Antlers/Runtime/Includes/. Same gap in the other direction for the src form — Antlers has test_it_renders_a_view_using_the_src_form, Blade doesn't test <s:include src="..." />.

New trait members are private in an otherwise protected trait. CompilesPartials was entirely protected; the six new members (compileSlotOutput, compileIncludeSlot, rawSlotName, isValidSlotName, compileInclude, compileViewTag) are private. compilePartial() stays protected and delegates, so the existing extension point survives — consistency nit only.

GlobalRuntimeState::captureRuntimeState() going from 3 to 4 elements is handled safely. restoreState() uses $capturedState[3] ?? true, and list-destructuring in third-party code ignores the extra element. Just flagging that I checked.

Comment thread src/Tags/IncludeTag.php
throw new RuntimeException('The [params] parameter on the include tag must be an associative array.');
}

return Arr::except($spread, self::CONTROL);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arr::except($spread, self::CONTROL) silently drops any spread key named src, when, unless, cascade, params, or handle_prefix. <s-include:card :params="entry" /> where the entry has a field called src (plausible for image/video/embed fields) loses it with no exception and no warning — the variable is just absent in the view. Compare RESERVED, which correctly throws (test_reserved_params_cannot_be_spread).

None of the control params are ever read from the spread: shouldRender() reads $this->params, cascade comes from $this->params->bool('cascade'), handle_prefix from $parameters['handle_prefix'], and src from wildcard(). So this except() isn't protecting anything — it only loses data.

It's also inconsistent with the prefix aliasing right below it. With handle_prefix="hero_", a spread key hero_src becomes src in unprefixedAliases() and is kept, while a directly-passed src is dropped.

Suggest dropping the except() entirely, or throwing the way RESERVED does so the loss is at least visible.

$val = $val->get()->all();
}

if ($val instanceof Slot) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This continue jumps past the modifier-handling block that starts immediately below at $executedParamModifiers = false;, so modifiers on a slot are never applied.

Worse, modifiers live in $node->parameters (see the ModifierManager::isModifier($param) loop a few lines down at ~2186), so for {{ slot | upper }}:

  • $node->hasParameters is true, so getSlotOutputProps() evaluates upper as a parameter and passes it into the slot's render data as a variable;
  • the continue means upper is never applied to the output.

Net result is silently wrong output plus a junk upper variable in the slot scope. Same for {{ slot:footer | markdown }}, | trim, and so on.

getSlotOutputProps() should filter out params where ModifierManager::isModifier() is true and let the modifier chain run on the rendered string — or slot nodes should reject modifiers loudly rather than absorbing them as props.

I traced this by reading rather than executing, so if the parser classifies modifier params differently here I'd like to be corrected — but either way a test for {{ slot | upper }} is worth adding.

$context = '$'.IncludeTag::CONTEXT_KEY.' ?? false';
$output = '\Statamic\View\Slot::output('.$slot.', '.$this->compileParameters($component->parameters).')';

return '<?php if ('.$context.') { echo '.$output.'; } else { ?>'.$this->compileComponent($component).'<?php } ?>';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The include branch emits only Slot::output(...) and discards the component's inner content, so a paired <s:slot:footer>Default</s:slot:footer> in an included Blade view renders nothing when the slot isn't supplied — Slot::output(null, []) returns '' — rather than falling back to the body.

Antlers has a working idiom for this ({{ if slot:header }}…{{ else }}Default{{ /if }}, covered by SlotsTest::test_a_named_slot_falls_back_to_the_views_default_when_not_provided). Blade users can reach for @if(isset($footer)) via the named-slot alias, but only when canAliasSlot() permits it — if the slot name collides with a data key or is one of PROTECTED_ALIASES (app, errors, params, view), there's no supported way to test for the slot, since $__statamic_include_slots is internal.

Either way, no test covers a paired <s:slot> inside an included Blade view. Worth deciding whether pair bodies are fallbacks or are intentionally ignored, and pinning it with a test.

$this->stopMeasuringTag();

if ($suspendedData != null) {
if ($capturedRuntimeState !== null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth calling out in the release notes: this guard change applies to every isolated tag, not just include. Previously an isolated tag invoked with empty data never reached GlobalRuntimeState::restoreState(), leaking requiresRuntimeIsolation and isCascadeEnabled.

The fix is correct and CascadeTest::test_any_isolated_tag_restores_the_previous_cascade_state covers it, but third-party isolated tags that (knowingly or not) relied on the cascade being force-re-enabled after they ran will now see the caller's state instead. No change requested — just shouldn't ship silently.


$existingView = $this->view;

$suspendedData = $this->nodeProcessor->getAllData();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same category as the NodeProcessor guard change: parseView() is the shared view-render path, so snapshotting and restoring the node processor's data here affects partial too, not just include.

The full suite is green so nothing regressed, but the PR description says partial semantics are deliberately unchanged — worth a second look to confirm that holds here, since this is exactly the mechanism behind the #8175-style leak.

Comment thread src/View/Slot.php
return $this->render();
}

public function __serialize(): array

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This renders eagerly with no props on serialize. For a scoped slot — one the view feeds data back into, e.g. {{ slot:row :cell="value" }} — serialization into a nocache fragment or the static cache bakes in a props-less render.

InteropTest::test_the_cache_tag_works_around_and_inside_an_include_with_slots covers the cache tag, and there isn't much alternative once a closure has to cross a serialize boundary. Mostly flagging that it degrades quietly rather than loudly — a scoped slot that ends up serialized will render wrong with no signal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants