The frontend build uses Webpack with Babel, SCSS compilation, and JavaScript obfuscation in production. Assets are compiled to public/build/ and served statically. In production, a manifest.json maps logical asset names to content-hashed filenames; the manifest_asset() PHP helper reads this manifest so templates always reference the correct file.
Frontend dependencies are managed via yarn (v4).
Install dependencies:
yarn install
The TypeScript entry point is src/index.ts:
// src/index.ts
import './style/_app.scss';
document.addEventListener( 'DOMContentLoaded', async () => {
// your application code
} );
All TypeScript and SCSS flows through this single entry point. Webpack resolves imports from here and bundles everything into public/build/.
Styles live in src/style/. The main file is _app.scss:
// src/style/_app.scss
@charset "UTF-8";
// import partials
@import 'variables';
@import 'layout';
@import 'components';
// etc.
The SCSS pipeline uses MiniCssExtractPlugin.loader → css-loader → sass-loader. CSS is extracted into a separate file (not inlined via style-loader).
webpack.config.js exports a function that receives the env object from CLI arguments — mode and cache flags drive build behaviour:
module.exports = ( env ) => {
const isProduction = env.mode === 'production';
return {
mode: env.mode,
entry: { app: './src/index.ts' }, // named entry → output is app.[hash].js
cache: Boolean( env.cache ),
devtool: isProduction ? false : 'inline-source-map',
output: {
filename: isProduction ? '[name].[contenthash].js' : 'app.js',
path: path.resolve( __dirname, 'public/build' ),
library: 'app',
},
// ...
};
};
| Mode | Filename | Source map |
|---|---|---|
| Development | app.js (fixed, no hash) |
Inline (embedded in file) |
| Production | app.[contenthash].js (cache-busting hash) |
None — never emitted |
Source maps are disabled in production (devtool: false). Emitting a .map file would expose the pre-obfuscated source in browser DevTools, completely defeating the obfuscator.
The entry is declared as { app: './src/index.ts' } (a named entry object) so webpack uses app as [name], producing consistent app.[hash].js filenames rather than the default main.[hash].js.
In development the output is always public/build/app.js. In production the content hash changes whenever the file content changes, ensuring browsers pick up the new file. The manifest_asset() PHP helper resolves the correct filename automatically — see Asset helpers below.
# Development build with file watching
yarn dev
# Production build
yarn build
Under the hood:
{
"scripts": {
"dev": "webpack --watch --env mode=development --env cache=0 --progress",
"build": "webpack --env mode=production --env cache=1 --progress"
}
}
yarn dev watches for file changes and rebuilds automatically. Use this during active frontend development. The --env cache=0 flag disables Webpack's persistent cache in dev mode so changes are always reflected immediately.
yarn build produces a minified, obfuscated production bundle with content-hashed filenames. No source maps are emitted.
Three production-only plugins run during yarn build:
new TerserPlugin( {
parallel: true,
terserOptions: {
format: {
comments: false, // strip all comments
},
compress: {
drop_console: true, // remove console.* calls
},
},
extractComments: false,
} )
All console.log(), console.warn(), console.error() calls are stripped from the production bundle. Add any debugging console calls freely during development — they won't make it to production.
new WebpackManifestPlugin( { publicPath: '/build/' } )
Emits public/build/manifest.json mapping logical entry names to their hashed filenames:
{
"app.js": "/build/app.dc2b151aac9bb7095ff3.js"
}
The PHP manifest_asset() helper reads this file so templates can reference app.js by logical name without hardcoding the hash. Only emitted in production — in development the filename is always app.js so no manifest is needed.
The obfuscator runs after minification and applies layered protections:
new JavaScriptObfuscator( {
// --- domain lock ---
domainLock: buildDomainLock(), // dynamic from DOMAIN_LOCK env + LAN IP + EXTRA_HOSTS
// --- devtools disruption ---
debugProtection: false,
disableConsoleOutput: true,
// --- structural obfuscation ---
compact: true,
selfDefending: false,
controlFlowFlattening: false,
deadCodeInjection: true,
deadCodeInjectionThreshold: 0.4,
numbersToExpressions: true,
// --- identifier renaming ---
identifierNamesGenerator: 'hexadecimal',
renameGlobals: false,
simplify: true,
// --- string obfuscation ---
splitStrings: true,
splitStringsChunkLength: 5,
stringArray: true,
stringArrayEncoding: [ 'base64' ],
stringArrayThreshold: 0.75,
stringArrayCallsTransform: true,
stringArrayCallsTransformThreshold: 0.75,
stringArrayIndexShift: true,
stringArrayRotate: true,
stringArrayShuffle: true,
stringArrayWrappersCount: 2,
stringArrayWrappersChainedCalls: true,
stringArrayWrappersParametersMaxCount: 4,
stringArrayWrappersType: 'function',
unicodeEscapeSequence: false,
log: false,
}, [ 'vendor.*.js' ] )
What each layer does:
Domain lock — the bundle only executes on domains returned by buildDomainLock(). This function reads the DOMAIN_LOCK env var (comma-separated), appends the machine's LAN IP (auto-detected or from LAN_IP env), and any EXTRA_HOSTS entries. If someone downloads the JS and runs it on a different origin, it fails silently. Set DOMAIN_LOCK in your .env before deploying to a new domain.
debugProtection: false — debug protection is disabled. The bundle does not fire debugger statements or attempt to freeze DevTools.
controlFlowFlattening: false — control flow flattening is disabled. The readable if/for/while structure is preserved (other obfuscation layers still apply).
deadCodeInjection — injects fake but convincing unreachable code paths (~40% of nodes). Static analysis tools and humans waste time tracing paths that never execute.
numbersToExpressions — 42 becomes something like 0x1f + 0xb. Literal constant searches stop working.
splitStrings + stringArrayEncoding: ['base64'] — strings are split into 5-character chunks, extracted into an array, and base64-encoded at rest; decoded only at runtime. They never appear as readable literals in the bundle source.
stringArrayWrappersType: 'function' + wrappersCount: 2 — two layers of function indirection wrap every string lookup. Tracing a string to its value requires following multiple call chains.
selfDefending: false — self-defending is disabled. The bundle does not detect formatting or modification.
new CleanWebpackPlugin( {
cleanStaleWebpackAssets: false,
} )
Cleans public/build/ before each build.
Never put secrets in variables you expose to the frontend — anything in the bundle is visible to anyone who downloads it regardless of obfuscation.
The output is configured with library: 'app':
output: {
library: 'app',
// ...
}
This exposes the bundle as a global window.app object in the browser. Exported functions and objects from src/index.ts are accessible as app.something from inline scripts in templates.
The header template sets the current route name on the app object after the bundle loads:
// In head.php
<script>
app.router.setCurrentRouteName( '<?= Router::$currentRoute->name ?>' );
</script>
This bridges the PHP router and the JavaScript layer — client-side navigation and AJAX calls can reference the current route by name.
asset()Validates that a file exists in public/ before returning its public URL:
function asset( string $path ): string
{
if ( file_exists( sprintf( '%s/public/%s', project_dir(), $path ) ) === false )
throw new FileNotFoundException( "Asset not found: $path" );
return "/$path";
}
If the file doesn't exist — build hasn't run, wrong path, file was cleaned — asset() throws FileNotFoundException immediately rather than rendering a broken script tag that silently fails. Use this for static assets whose filenames never change (jQuery, Bootstrap, images, etc.).
build_manifest()Internal shared loader for the manifest JSON. Reads public/build/manifest.json once and caches the result in a static variable for the lifetime of the request:
function build_manifest(): array
{
static $manifest = null;
if ( $manifest === null ) {
$manifestPath = project_dir() . '/public/build/manifest.json';
$manifest = file_exists( $manifestPath )
? json_decode( file_get_contents( $manifestPath ), true )
: [];
}
return $manifest;
}
Most applications won't call this directly — manifest_asset() and manifest_has() both delegate to it. It is available as a public function if you need to inspect the full manifest contents (e.g. for custom asset loading logic).
manifest_has()Returns true if a given logical filename is present in the manifest and the resolved file physically exists on disk:
function manifest_has( string $filename ): bool
Use this to conditionally include optional build chunks that may or may not be present in a given build (for example a lazily-split vendor chunk):
// In head.php — only include the vendor chunk if it was emitted in this build
<?php if ( manifest_has( 'vendor.js' ) ) : ?>
<script src="<?= manifest_asset( 'vendor.js' ) ?>"></script>
<?php endif ?>
<script src="<?= manifest_asset( 'app.js' ) ?>"></script>
manifest_has() resolves the filename through the manifest first (falling back to build/$filename in development), then calls file_exists() on the resolved path. It does not throw — it returns false for any missing file.
manifest_asset()Resolves a logical asset name through public/build/manifest.json to the correct content-hashed URL, then delegates to asset() for existence validation:
function manifest_asset( string $filename ): string
Delegates to build_manifest() for the manifest lookup (shared static cache).
| Mode | manifest.json exists? |
Resolution |
|---|---|---|
| Production | Yes | Reads manifest.json, returns hashed URL (e.g. /build/app.dc2b15....js) |
| Development | No | Falls back to build/$filename — matches the fixed app.js dev output |
Use manifest_asset() for the main application bundle. Use asset() for everything else. Use manifest_has() to check whether an optional chunk was emitted before including it.
Usage in templates:
<?php if ( manifest_has( 'vendor.js' ) ) : ?>
<script src="<?= manifest_asset( 'vendor.js' ) ?>"></script>
<?php endif ?>
<script src="<?= manifest_asset( 'app.js' ) ?>"></script>
<script src="<?= asset( 'js/jquery-3.6.0.min.js' ) ?>"></script>
<script src="<?= asset( 'js/bootstrap.min.js' ) ?>"></script>
The startup script launches the development server:
#!/usr/bin/env bash
port=${1:-7000}
# Use Symfony CLI if available (HTTPS), fall back to PHP built-in server
if ! [ -x "$(command -v symfony)" ]; then
use_symfony_cli=false
else
use_symfony_cli=true
fi
# Open browser
if [ "$use_symfony_cli" = true ]; then
open_browser "https://127.0.0.1:$port"
symfony serve --port="$port"
else
open_browser "http://127.0.0.1:$port"
php -S 127.0.0.1:"$port" -t public
fi
# Start on default port 7000
./run.sh
# Start on a custom port
./run.sh 8080
Symfony CLI is used when available because it provides HTTPS via a self-signed certificate, which is required by the acceptance test suite (acceptInsecureCerts: true in Acceptance.suite.yml). The PHP built-in server is the fallback for environments without Symfony CLI — HTTP only, port 7000.
The script attempts to open the browser automatically using gio open, xdg-open, or open depending on the OS. If none of these are available it prints the URL to the console.
The template project includes CKEditor as a separate frontend build in public/CKEditor/:
# Install CKEditor dependencies
cd public/CKEditor
npm install
# Build CKEditor
npm run build
CKEditor is activated via the blank middleware pattern — Kernel::setEditorStatus(true) is called from the editor_component view, and footer.php checks Kernel::isEditorActivated() to conditionally import the CKEditor_activator component:
// In footer.php
<?php if ( Kernel::isEditorActivated() ) : ?>
<?php $this->import( CKEditor_activator::class ) ?>
<?php endif ?>
This means the CKEditor scripts are only loaded on pages that explicitly include the editor_component view — no unnecessary script loading on pages that don't need it.
The editor converts its Markdown output to HTML via Showdown before form submission:
// In CKEditor_activator.php
$( 'form#editor' ).on( 'submit', ( event ) => {
event.preventDefault();
$( '#editor_data' )[0].value = converter.makeHtml( editor.getData() );
event.currentTarget.submit();
} );
Forgetting to update domain lock before deploying — the production bundle only executes on domains returned by buildDomainLock(). If you add a new domain or change your production URL and forget to update the DOMAIN_LOCK env var and rebuild, the JavaScript silently fails on the new domain with no error message. Update DOMAIN_LOCK and run yarn build as part of every domain change.
Using asset() for the main bundle — in production the main bundle has a content-hash suffix (app.abc123.js). Always use manifest_asset('app.js') for the main bundle, not asset('build/app.js'). The latter throws FileNotFoundException in production because the fixed filename doesn't exist.
Running yarn build in development — production builds apply obfuscation which makes debugging impossible. Use yarn dev during development and only run yarn build when preparing a production deployment.
Putting sensitive env vars in JavaScript — dotenv-webpack bundles env vars into the JavaScript output. Even with obfuscation, determined attackers can extract values from the bundle. Never put API secrets, database credentials, or private keys in variables accessed from JavaScript. Use them server-side only.