Migrating Hyvä themes and modules to strict CSP

Standard Alpine evaluates attribute expressions with the Function constructor, which requires unsafe-eval in your Content Security Policy (CSP). Hyvä’s Alpine CSP build removes that requirement, but attributes can then only read a property or call a method on the component. Keep your HTML clean, if it calculates values, formats data, or updates state, put it in the component script instead.

Removing unsafe-eval from your headers does not convert templates. Custom and third-party templates must be migrated by hand. The CSP build exists only for Alpine v3.

ℹ️ Some behaviours, such as this.item inside x-for and the '!prop'() shorthand, depend on the Alpine version your Hyvä release bundles. Verify them before relying on them.

Authorizing Inline Scripts

Inline scripts must be authorized by the CSP. Uncached pages use a nonce. Cached pages use a SHA-256 hash of the script content, because a nonce can’t be reused from the full-page cache. This is why a script can work uncached and fail once cached: if a minifier, FPC plugin, or stray blank line changes the content after the hash is calculated, the hash no longer matches and the browser blocks the script.

Call $hyvaCsp->registerInlineScript() directly after the closing </script> tag, once per inline script, with nothing between them (no HTML, blank lines, or whitespace).

<script>
    function myComponent() {
        return { /* ... */ };
    }
</script><?php $hyvaCsp->registerInlineScript() ?>

ℹ️ $hyvaCsp exists only in Hyvä themes. In modules that support both Hyvä and Luma, use it only in templates loaded under hyva_* layout handles.

Automated Migration

Run Hyvä CSP tool before refactoring by hand:

bin/magento hyva:csp:migrate [path/to/template-or-directory]

Migration Rules

Operators, arguments, and object literals in attributes will fail. Move them into methods.

Standard AlpineWorks?CSP replacement
x-show="!isLoading"NoMethod isNotLoading(), or '!isLoading'()
x-text="title && label"NoMethod returning the joined string
:class="a ? 'x' : 'y'"NoMethod returning the class name
:class="{ active: isActive }"NoMethod returning a class string or object
@click="open = !open"No@click="toggle"
@click="select(5)"Nodata-id="5", read via this.$el.dataset.id
x-data="{ open: false }"NoAlpine.data('name', ...) → x-data="name"
x-data="init()"Nox-data="init"
x-model="value"No:value + @input / @change handler
x-for="p in getItems(id)"Nox-for="p in getItems" (no arguments)
x-for="i in 10"NoBuild a real array in JS
x-html="markup"Nox-html-safe (Hyvä 1.5.3+), trusted server markup only; otherwise x-text
onclick="..."NoBlocked by CSP; use @click="handler"
x-show="item.visible"YesProperty lookups and dot notation work
<div x-show="isNotLoading"></div>
<button @click="toggle">Toggle</button>
<div :class="activeClass"></div>
function myComponent() {
    return {
        isLoading: false,
        open: false,
        isActive: true,

        isNotLoading() { return !this.isLoading; },
        toggle() { this.open = !this.open; },
        activeClass() { return this.isActive ? 'active' : 'inactive'; }
    };
}

Component Registration

Don’t pass object literals or function calls to x-data. Declare the component globally, then register it in an alpine:init listener.

function initComponent() {
    return { open: false, query: '' };
}

window.addEventListener('alpine:init', () => {
    Alpine.data('initComponent', initComponent);
}, { once: true });
<div x-data="initComponent"></div>

Method Arguments and x-for

Handlers can’t take arguments. Put the value in a data-* attribute and read it from the element. Dataset values are always strings, so convert them (parseInt(), JSON.parse()) before comparing.

Inside x-for, the current item is available as this.item, so no data-* attribute is needed. Arguments in the loop expression (getItems('category')) and range loops (i in 10) are not supported.

<button type="button" @click="selectProduct"
        data-product-id="<?= (int) $product->getId() ?>">Select</button>

<template x-for="item in getItems" :key="item.id">
    <button type="button" @click="removeItem">Delete</button>
</template>
selectProduct() {
    this.selected = parseInt(this.$el.dataset.productId, 10);
},
removeItem() {
    const id = this.item.id;
    this.items = this.items.filter(entry => entry.id !== id);
}

Replacing x-model

x-model is not supported. Bind :value and handle the event manually.

ElementBindingListenerRead value via
Text, email, date, textarea:value="term"@input="setTerm"this.$event.target.value
Checkbox, radio:checked="isChecked"@change="toggle"this.$event.target.checked / .value
Select:selected="isSelected" (on option)@change="update"this.$el.value

Modifiers:

.number hyva.safeParseNumber(this.$event.target.value) **.lazy**listen to @change instead of @input .debounce / .throttle implement inside the method

updateSelection() {
    const { value, checked } = this.$event.target;
    this.selection = checked
        ? [...new Set([...this.selection, value])]
        : this.selection.filter(v => v !== value);
}

Passing PHP Data to JavaScript

Don’t print raw PHP into JS. JSON-encode it with JSON_HEX_* flags (escapes < > ' " &) and pass it through an escaped data-* attribute.

<?php
$config = json_encode(
    ['id' => (int) $block->getProductId(), 'sku' => $block->getSku()],
    JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_THROW_ON_ERROR
);
?>
<div x-data="productConfig" data-config="<?= $escaper->escapeHtmlAttr($config) ?>"></div>
init() {
    const config = JSON.parse(this.$el.dataset.config || '{}');
    this.id = config.id;
    this.sku = config.sku;
}

Avoid Dynamic PHP Inside <script>

If script text changes per product, the CSP gets a new hash for every product. On a large category grid the headers can exceed your server’s buffer limits (Nginx: 502 Bad Gateway, “upstream sent too big header”). Raising proxy_buffer_size or fastcgi_buffer_size only hides the problem. Keep the script text identical for every product and pass dynamic values through data-* attributes.

<!-- Antipattern: a new SHA-256 hash per product -->
<script>
function productConfig_<?= (int) $block->getProductId() ?>() {
    return { id: <?= (int) $block->getProductId() ?> };
}
</script>

Magewire and Hyvä Checkout

Magewire replaces checkout blocks over AJAX, and the CSP blocks any inline <script> returned in an update. It works on first load and breaks silently on refresh.

Keep template.phtml to markup, data-* attributes, and Alpine directives. Move the JS and $hyvaCsp->registerInlineScript() into template-csp-js.phtml. Load it upfront in hyva_checkout.xml.

<referenceContainer name="magewire.plugin.scripts">
    <block class="Magento\Framework\View\Element\Template"
           name="vendor.checkout.custom.js"
           template="Vendor_Module::template-csp-js.phtml" />
</referenceContainer>

Debugging

Error / symptomCause and fix
Refused to execute inline script...Missing or mismatched hash. See Authorizing Inline Scripts.
Refused to evaluate a string as JavaScript...Standard Alpine still loads somewhere, or unmigrated code uses eval() / new Function().
Alpine Expression Error / CSP parser errorsAn expression, negation, assignment, or argument is left in an attribute. Move it into a method.
Checkout breaks after step 2Script returned via AJAX. Load it upfront through magewire.plugin.scripts.

CI Checks

Fail the build on these patterns:

SCAN_PATHS="app/code app/design"

# 1. x-model
grep -rnE --include='*.phtml' 'x-model(\.[a-zA-Z0-9]+)*=' $SCAN_PATHS

# 2. Obsolete directives
grep -rnE --include='*.phtml' '(x-html|x-spread)=' $SCAN_PATHS

# 3. Object literals or calls inside x-data
grep -rnE --include='*.phtml' 'x-data="[^"]*[{(]' $SCAN_PATHS

# 4. Inline event handlers
grep -rnE --include='*.phtml' ' on[a-z]+="' $SCAN_PATHS

# 5. Dynamic PHP inside function names
grep -rnE --include='*.phtml' 'function [A-Za-z0-9_]+_<\?=' $SCAN_PATHS

If you haven’t set up CSP yet, start now before it becomes an emergency. There’s a small learning curve, but the most important thing is to keep your HTML clean and move all the logic into your JS.

This article was updated on October 19, 2026