Alpine.js CSP in Hyvä: The Problems You'll Actually Hit (and How to Fix Them)
Converting a simple Alpine component to the CSP build takes five minutes. Converting a real Magento module takes longer, because production templates mix PHP-generated values, inline scripts, Hyvä helpers, block caching and checkout fragments, and none of that was written with a strict Content Security Policy in mind.
This guide walks through the patterns that break, in the order they are most likely to hurt, with the fix for each. Everything here is checked against the official Hyvä documentation (last checked 16 October 2026). Each section links to its source page.
[Author: add your tested versions here, e.g. Magento 2.4.x, Hyvä Theme x.y.z, Alpine x.y.z, and the date you tested.]
TL;DR
Under strict CSP, Alpine can no longer turn attribute text into JavaScript. Attributes may only read a property or reference a method. Everything else has to move into the component.
| Standard Alpine | Works in CSP build? | Fix |
|---|---|---|
x-show="!isLoading" | No | Method, e.g. isNotLoading(), or a ['!isLoading']() method |
:class="a ? 'x' : 'y'" | No | Method that returns the value |
@click="open = !open" | No | Method toggle() |
@click="select(5)" | No | data-* attribute, read via this.$el.dataset |
x-data="{ open: false }" | No | Named function registered with Alpine.data() |
x-data="init()" | No | x-data="init" after registration |
x-model="value" | No | :value plus @input / @change handler |
x-for="p in getProducts(id)" | No | x-for="p in getProducts" (no arguments) |
x-for="i in 10" | No | Iterate a real array |
x-show="item.visible" | Yes | Dot notation on properties works |
<script> with inline code | Only if authorized | $hyvaCsp->registerInlineScript() |
Before you start
Why migrate at all
Hyvä’s documentation ties the requirement to compliance: since 1 April 2025, PCI-DSS 4.0 requires a strict CSP (without unsafe-eval) on payment-related pages, although the documentation notes that the exact scope of “payment-related pages” is ambiguous. In practice, any store that takes card payments, or that wants to turn on strict CSP, needs its templates to run without unsafe-eval and unsafe-inline.
What the two restrictions actually mean
- No
unsafe-eval. Standard Alpine compiles attribute text into functions at runtime. That is forbidden, so Hyvä uses the Alpine CSP build, where attributes may only reference predefined properties and methods. - No
unsafe-inline. Inline<script>tags only run if they are authorized by a nonce (uncached pages) or a SHA-256 hash in the CSP header (full-page-cached pages). Inline event handlers such asonclick="..."are blocked as well.
Requirements
- Alpine v3. There is no CSP build of Alpine v2, so a v2 theme must upgrade first. Check with
Alpine.versionin the browser console. - When you turn off
unsafe-eval, Hyvä switches to the Alpine CSP build automatically.
Test before you enforce
Hyvä’s Magento configuration page covers three settings:
| Setting | Value to enforce strict CSP |
|---|---|
csp/mode/storefront/report_only | 0 |
csp/policies/storefront/scripts/eval | 0 |
csp/policies/storefront/scripts/inline | 0 |
n98-magerun config:env:set system/default/csp/policies/storefront/scripts/inline 0
n98-magerun config:env:set system/default/csp/policies/storefront/scripts/eval 0
n98-magerun config:env:set system/default/csp/mode/storefront/report_only 0
bin/magento app:config:import
Magento also supports per-website and per-store-view scope by replacing system/default with system/websites/<code> or system/stores/<code> in those paths.
Two practical notes:
- Magento supports a report-only mode, which reports violations without blocking anything. Hyvä’s page doesn’t describe it in detail, so check Adobe’s Content Security Policies documentation for how to use it on your version, and use it on staging first.
- To see violations, enable the Warning log level in the browser console. Alpine’s warning names the failing expression and the element that caused it, which is the fastest way to find the line to fix.
Also be aware of two caveats from the same page: disabling unsafe-inline breaks scripts in CMS content, and Magento’s built-in PHP full-page cache doesn’t store the CSP header, so cached pages can have inline scripts blocked (Varnish and Fastly cache the header and are not affected).
Source: Hyvä: Configuring CSP in Magento, Hyvä: CSP compatibility, Hyvä: Alpine CSP
The problems, most common first
1. Inline expressions in attributes
This is the problem you will hit in nearly every template, so start here. Look at every Alpine directive in your .phtml files, not only @click: x-show, x-if, x-text, :class, :style, :disabled and the rest.
What the documentation says is not supported inside an attribute:
- negation:
x-show="!item.deleted" - logical operators:
x-text="item.title || item.value" - ternaries and inline object expressions:
:class="{'hidden': !open}" - assignments:
@click="open = false"
What works: reading a property, and dot notation on nested properties (x-show="item.is_visible").
<!-- Before -->
<div x-show="!isLoading"></div>
<button @click="open = !open">Toggle</button>
<div :class="isActive ? 'active' : 'inactive'"></div>
<!-- After -->
<div x-show="isNotLoading"></div>
<button @click="toggle">Toggle</button>
<div :class="activeClasses"></div>
isNotLoading() {
return !this.isLoading;
},
toggle() {
this.open = !this.open;
},
activeClasses() {
return this.isActive ? 'active' : 'inactive';
}
For negation, Hyvä also documents a shortcut: declare a method whose name includes the !, then use it in the template as written.
return {
deleted: false,
['!deleted']() {
return !this.deleted;
}
}
<template x-if="!deleted">
If you have a lot of boolean flags, the documentation also points to hyva.createBooleanObject to cut the boilerplate.
Source: Alpine CSP component properties, Alpine CSP property mutation
2. x-data must be a registered name
Inline objects (x-data="{ selected: null }") and constructor calls (x-data="initProductComponent()") both fail. Declare a named global function, register it with Alpine.data() inside an alpine:init listener, and reference it by name:
<div x-data="initProductComponent">
function initProductComponent() {
return {
selected: null
};
}
window.addEventListener('alpine:init', () => {
Alpine.data('initProductComponent', initProductComponent);
}, { once: true });
Registering in alpine:init matters in Magento. Scripts can come from different layout blocks, and Alpine.data is likely not available yet when the inline script is parsed. Hyvä uses named global functions, rather than anonymous arrow functions, so other code can proxy or wrap them.
Source: Alpine CSP constructor functions
3. Method arguments become data-* attributes
Passing arguments from an attribute is impossible in the CSP build, because the attribute would have to be evaluated as a JavaScript expression. The documented alternative is a data attribute:
<!-- Before -->
<button @click="selectItem(<?= (int) $item->getId() ?>)">Select</button>
<!-- After -->
<button @click="selectItem" data-item-id="<?= (int) $item->getId() ?>">
Select
</button>
selectItem() {
this.selected = this.$el.dataset.itemId;
}
Two things to remember:
datasetvalues are always strings. Ifthis.selectedpreviously held a number and you compare with===, the comparison now silently fails. Convert withparseInt()(orNumber()) where needed.- Inside
x-for, you don’t need a data attribute at all. The iteration variable is available onthis, and so isthis.$event:
<template x-for="item in items">
<input @click.prevent="processClick"/>
</template>
processClick() {
const value = this.$event.target.value;
if (value === this.item.id) { // `item` is the x-for variable
// ...
}
}
Source: Alpine CSP property mutation
4. x-model has to be rebuilt by hand
x-model can’t be used with the CSP build, because updating the bound property means creating and evaluating an expression. The replacement depends on the input type:
| Element | Replacement |
|---|---|
Text-like inputs (text, email, date…) | :value="prop" and @input="setProp" |
<textarea> | @input="setProp" and x-text="prop" (content, not an attribute) |
| Checkbox and radio | @change and :checked="isChecked" |
<select> | @change on the select, :selected="isSelected" on each option |
<input type="text" :value="prop" @input="setProp">
setProp() {
this.prop = this.$event.target.value;
}
Modifiers need attention too. For .number, Hyvä provides hyva.safeParseNumber, which uses the same code as Alpine’s modifier:
this.numericProp = hyva.safeParseNumber(this.$event.target.value);
For .lazy, .boolean, .debounce, .throttle and .fill, the documentation states there are no utility methods at the time of writing, so you have to reproduce that behavior yourself. The migration tool converts x-model to :value plus @change, but select, checkbox and radio inputs still need manual adjustment, so test the result.
Source: Alpine CSP x-model
5. x-for: no arguments, no numeric ranges
x-for works, with two restrictions. The value provider is a property or a method referenced without parentheses and without arguments, and numeric ranges are not supported.
<!-- Not supported -->
<template x-for="(product, index) in getProducts(selected)">
<template x-for="i in 10">
<!-- Supported -->
<template x-for="(product, index) in getProducts">
<template x-for="(product, index) in products">
getProducts() {
return this.products.filter(
product => product.category === this.selected
);
}
For a numeric range, build the array yourself, for example Array.from({ length: 10 }, (_, i) => i + 1), and iterate over that. Inside any method called from the template, the iteration value and index are available on this (this.product.id).
Source: Alpine CSP x-for
6. Inline scripts must be authorized, and PHP-generated scripts multiply
This is the part of unsafe-inline that catches people who fixed every Alpine expression and still get a dead page.
Register every inline script. Call $hyvaCsp->registerInlineScript() directly after the closing </script> tag, with no HTML in between, because the hash is calculated from the script immediately before the call. If a template has several inline scripts, call it after each one.
<script>
// Your JavaScript code here
</script>
<?php $hyvaCsp->registerInlineScript() ?>
$hyvaCsp exists in Hyvä Themes and the Hyvä Admin theme only, not on Luma. If your module supports both Hyvä and Luma, use it only in frontend templates declared inside hyva_* layout handles, to avoid undefined-variable errors.
Don’t generate script text with PHP. Hyvä’s documentation uses this “before” example, where the product ID is baked into the function name and its body:
<div x-data="myFunction_<?= $escaper->escapeJs($block->getProductId()) ?>"></div>
<script>
function myFunction_<?= $escaper->escapeJs($block->getProductId()) ?>() {
return {
id: <?= $escaper->escapeJs($block->getProductId()) ?>,
config: <?= $escaper->escapeJs($block->getJsonConfig()) ?>
}
}
</script>
Every product produces a different script, so every product adds a different sha256 hash to the CSP header. The documentation warns this can lead to “Header too large” errors. The fix is one constant script and the dynamic values in data attributes:
<div x-data="myFunction"
data-product-id="<?= $escaper->escapeHtmlAttr($block->getProductId()) ?>"
data-config="<?= $escaper->escapeHtmlAttr($block->getJsonConfig()) ?>">
</div>
<script>
function myFunction() {
return {
id: parseInt(this.$el.dataset.productId),
config: JSON.parse(this.$el.dataset.config)
}
}
</script>
<?php $hyvaCsp->registerInlineScript() ?>
Note the escaper changes from escapeJs to escapeHtmlAttr, because the value now lives in an HTML attribute rather than in JavaScript. For JSON, make sure getJsonConfig() really returns a JSON string (if you have an array, json_encode() it first), then let escapeHtmlAttr encode the quotes. The browser decodes them again, so JSON.parse(this.$el.dataset.config) receives valid JSON. Remember dataset values are strings, hence parseInt.
Hyvä also mentions an experimental hyva-themes/magento2-minification module that merges inline JS so only a single hash is added.
Source: CSP compatibility, CSP Migration Tool: avoid unique scripts
7. Scripts inside block_html-cached blocks
A component can be perfectly CSP-compatible and still be blocked, because of how nonces and hashes interact with Magento’s caches.
- Uncached pages use nonces. The nonce must change on every request. If the script sits in a block stored in
block_html, later requests are served that stored HTML, and in the documentation’s words “the nonce in the cached HTML will not match the nonce for the current request.” The browser blocks the script. - Full-page-cached pages use hashes. If the block is already in
block_htmlwhen the page is first rendered, its script is never registered, so its hash is missing from the CSP header. This often happens when one cached block appears on both cached and uncached pages.
The documentation says the only solution is to exclude scripts from blocks cached in block_html, in one of two ways:
- Exclude the whole block from
block_html. This is the fastest fix, but the block is generated on every request. - Move the script into a separate, uncached template block, so the rest of the block stays cached.
To turn caching off for a block, set cache_lifetime to false at the same level where it was originally set (layout XML, block class or template):
<referenceBlock name="example">
<arguments>
<argument name="cache_lifetime" xsi:type="boolean">false</argument>
</arguments>
</referenceBlock>
Don’t “fix” this by disabling only the full-page cache: that makes the nonce problem appear on all pages. If you run strict CSP site-wide, keep full-page caching on, or disable both full_page and block_html. The menu is a known case: it is cached on every page, so on custom uncached routes the docs suggest excluding topmenu_generic the same way. To check in PHP whether a page can be full-page cached, use $this->layout->isCacheable().
Symptom to remember: works on the first uncached load, breaks after the cache warms up.
Source: CSP and block_html cache
8. Composing Hyvä helpers in x-data
Standard Hyvä often merges helpers directly in the attribute:
<form x-data="Object.assign(hyva.modal(), hyva.formValidation($el), {myValue: ''})">
That expression can’t be evaluated in the CSP build. Move the merge into a registered constructor:
function exampleCspComponent() {
return Object.assign(
hyva.modal.call(this),
hyva.formValidation(this.$el),
{ myValue: '' }
);
}
The detail that matters is context. If a helper expects the Alpine object context, call it with call() (as in hyva.modal.call(this)), because this and $el are bound to the Alpine component. Note that $el is passed as this.$el here. Register the function with Alpine.data() as in section 2.
Source: Alpine CSP constructor functions
9. Hyvä Checkout: scripts can’t arrive with the fragment
Magewire loads checkout content dynamically and cannot load scripts into the page after the initial load. A component whose template contains both markup and <script> therefore works on the first render and breaks when the fragment is re-rendered. The scripts must live in the content that is loaded initially.
The migration tool does this for checkout modules: it splits the component template from its script into a separate *-csp-js.phtml file, and adds or updates layout/hyva_checkout.xml with a block inside the magewire.plugin.scripts container. When the script contains inline PHP variables, the tool moves it unmodified and leaves a @TODO for you to turn it into an Alpine component by hand. Plan time for that.
Fixing the Alpine expressions alone will not repair a script that is loaded at the wrong point in the checkout lifecycle.
Source: CSP Migration Tool: move scripts to footer
A complete example, before and after
Individual snippets hide how the rules interact, so here is one small tabs component with a filter, using most of the patterns above.
Before (standard Alpine):
<div x-data="{ active: 'description', search: '', tabs: <?= $escaper->escapeHtmlAttr(json_encode($tabs)) ?> }">
<input type="search" x-model="search">
<template x-for="tab in tabs" :key="tab.id">
<button type="button"
@click="active = tab.id"
:class="active === tab.id ? 'font-bold' : ''"
x-text="tab.label"></button>
</template>
<div x-show="active !== 'description'">Extra content</div>
</div>
After (CSP build):
<div x-data="initProductTabs"
data-tabs="<?= $escaper->escapeHtmlAttr(json_encode($tabs)) ?>">
<input type="search" :value="search" @input="setSearch">
<template x-for="tab in tabs" :key="tab.id">
<button type="button"
@click="selectTab"
:class="tabClass"
x-text="tab.label"></button>
</template>
<div x-show="isNotDescription">Extra content</div>
</div>
<script>
function initProductTabs() {
return {
active: 'description',
search: '',
tabs: [],
init() {
this.tabs = JSON.parse(this.$el.dataset.tabs);
},
setSearch() {
this.search = this.$event.target.value;
},
selectTab() {
this.active = this.tab.id; // `tab` is the x-for variable
},
tabClass() {
return this.active === this.tab.id ? 'font-bold' : '';
},
isNotDescription() {
return this.active !== 'description';
}
};
}
window.addEventListener('alpine:init', () => {
Alpine.data('initProductTabs', initProductTabs);
}, { once: true });
</script>
<?php $hyvaCsp->registerInlineScript() ?>
What changed, in order: the inline object became a registered constructor (section 2); the PHP-generated JSON moved into a data attribute and out of the script, so the script is identical on every page (section 6); x-model became :value plus @input (section 4); the assignment, ternary and comparison became methods (section 1); and the x-for variable is read from this instead of being passed as an argument (section 3). The script is registered with registerInlineScript(), and it contains no PHP, so it adds one hash no matter how many pages use it.
[Author: replace this with a component from a real project, and add a screenshot of the console before and after.]
Common console messages and what they mean
The exact wording differs by browser, and the first three below are Chrome’s phrasing.
| What you see | Likely cause | Where to look |
|---|---|---|
Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script... | Code still using eval() or the Function constructor, or the standard Alpine build is loaded | Check Alpine.version and the loaded build; look for third-party scripts |
Refused to execute inline script because it violates the following Content Security Policy directive... | Inline script isn’t authorized | Missing registerInlineScript(), or block cached in block_html (section 7) |
Refused to execute inline event handler... | onclick="..."-style attributes | Replace with Alpine @click and a method |
| Alpine warning naming an expression and element | Attribute uses something the CSP build can’t evaluate | Negation, ternary, assignment, method arguments (sections 1 and 3) |
| Component does nothing, no CSP error | x-data name not registered, or still an inline object or call | Section 2 |
| Works uncached, breaks after cache warm-up | Nonce or hash mismatch from a cached block | Section 7 |
| Checkout component dead after a Magewire update | Script arrives with the dynamic fragment | Section 9 |
| “Header too large” / failing responses | Too many unique inline script hashes | PHP-generated scripts (section 6) |
Don’t assume the migration tool did everything
Hyvä ships a migration tool. Install it as a dev dependency and run it on a module, theme or project directory:
composer require --dev hyva-themes/upgrade-helper-tools:dev-main
composer exec hyva-csp-helper app/code/Vendor/Module
# save the report:
composer exec hyva-csp-helper app/code/Vendor/Module | tee CSP-updates.md
The documentation is blunt about it: never run this script in production, because it edits files in place. Review the diff before deploying. It requires PHP 8.1+ and hyva-themes/magento2-theme-module 1.3.11+, and hyva-themes/magento2-hyva-checkout 1.3.0+ for checkout.
What it does: converts Alpine attributes in frontend *.phtml files, replaces x-model with :value plus @change, turns an empty x-data into x-data="{}", moves inline PHP in attributes into data-* attributes (switching escapeJs to escapeHtmlAttr), generates Alpine script components, adds RegisterInlineScript, and handles the checkout script split.
What it does not do:
- It only reviews frontend
*.phtmlfiles. PHP files such as Models and ViewModels need manual migration. - It does not handle Alpine attributes generated from PHP arrays, such as
[':class' => 'someExpression']. These are common when rendering SVGs. - It does not migrate inline PHP that is part of a larger word.
- Checkout inline PHP variables always produce a
@TODO. - Select, checkbox and radio
x-modelreplacements need manual adjustment.
After running it, read the generated JavaScript, click through every interactive component, and watch the console under your real CSP configuration.
Source: Hyvä CSP Migration Tool
Find the problem patterns with grep
Run these from your module or theme directory. They are rough filters, so expect some false positives and always read the match in context.
# x-model anywhere
grep -rn --include=*.phtml 'x-model' .
# negation at the start of a binding
grep -rnE --include=*.phtml '(x-show|x-if|x-text|:[a-z-]+)="!' .
# ternaries inside attributes (excludes PHP tags)
grep -rnE --include=*.phtml '="[^"<>]*\?[^"<>]*:[^"<>]*"' .
# assignments in event handlers
grep -rnE --include=*.phtml '(@|x-on:)[a-z.:-]+="[^"<>]*[^=!<>]=[^=][^"]*"' .
# method calls with arguments in event handlers
grep -rnE --include=*.phtml '(@|x-on:)[a-z.:-]+="[^"<>]*\([^)"<>]+\)' .
# x-data with an inline object or a constructor call
grep -rnE --include=*.phtml 'x-data="[^"]*[{(]' .
# numeric x-for ranges
grep -rnE --include=*.phtml 'x-for="[a-z_]+ in [0-9]+"' .
# inline event handlers
grep -rnE --include=*.phtml ' on[a-z]+=' .
# templates that contain a <script> but never call registerInlineScript
grep -rl --include=*.phtml '<script' . | xargs grep -L 'registerInlineScript'
# Alpine attributes built in PHP arrays
grep -rnE --include=*.phtml --include=*.php "['\"](:|@|x-)[a-z.:-]+['\"] *=>" .
# PHP echoed into JavaScript identifiers or bodies
grep -rnE --include=*.phtml 'function [A-Za-z_]+_<\?=' .
Migration checklist
In priority order:
- Inline expressions: negation,
||/&&, ternaries, assignments and inline objects in every directive. x-datauses a registered name, registered inalpine:init.- Method calls with arguments replaced by
data-*attributes orthis.<iteration variable>. x-modelreplaced per input type, modifiers reviewed.x-forhas no arguments and no numeric range.- Every inline
<script>followed by$hyvaCsp->registerInlineScript(). - No PHP-generated function names or bodies; dynamic values moved to
data-*attributes withescapeHtmlAttr. - Scripts removed from
block_html-cached blocks, tested with cold and warm caches. - Helper compositions moved into constructors with
.call(this)where needed. - Checkout scripts split into
*-csp-js.phtmlfiles and registered throughhyva_checkout.xml. - PHP-array attributes, Models, ViewModels and third-party template overrides reviewed by hand.
- Tested under the real production CSP configuration, with the console open.
Conclusion
Most of these problems aren’t framework bugs. They are the difference between “Alpine builds JavaScript from my attributes” and “attributes may only point at things the component already defines”. Once you accept that rule, the fixes repeat: move logic into methods, move PHP values into data attributes, and make every inline script identical, registered and outside cached blocks.
A sensible order of work: run the migration tool on a copy, review its output against this list, fix the PHP-array and checkout leftovers by hand, then turn on strict CSP on staging and clear the console page by page before production.
Sources
- Hyvä: Alpine CSP
- Hyvä: Alpine CSP component properties
- Hyvä: Alpine CSP constructor functions
- Hyvä: Alpine CSP property mutation
- Hyvä: Alpine CSP x-model
- Hyvä: Alpine CSP x-for
- Hyvä: CSP compatibility
- Hyvä: Configuring CSP in Magento
- Hyvä: CSP and block_html cache
- Hyvä: CSP Migration Tool
- Adobe Commerce: Content Security Policies