Skip to content

johanjanssens/frankenwasm

Repository files navigation

FrankenWASM

WebAssembly plugin runtime for PHP — call Go, Rust, and JS WASM modules from PHP via FrankenPHP + Extism.

Plugins are sandboxed, portable .wasm files loaded at startup that run alongside your PHP application with near-native performance.

Note: This is a companion repo for my FrankenPHP conference talks. It's meant as inspiration and a reference implementation, not a production framework. Feel free to explore, fork, and adapt the patterns for your own projects.

Talks

FrankenWASM Demo

Plugins

20 plugins across three languages, from 256 KB to 8.3 MB:

Plugin Language Size Time Description
ascii-rs Rust 256 KB ~0.04 ms FIGlet-style ASCII art banners
blurhash-rs Rust 300 KB ~0.5 ms Blur hash encoding/decoding
langdetect-rs Rust 529 KB ~0.4 ms Language detection
toml-rs Rust 621 KB ~0.4 ms TOML ↔ JSON conversion
markdown-rs Rust 644 KB ~1 ms Markdown → HTML
sanitize-rs Rust 1.2 MB ~0.8 ms HTML sanitization (ammonia)
jsonpath-rs Rust 2.0 MB ~0.12 ms JSONPath queries
scss-rs Rust 2.7 MB ~0.2 ms SCSS/Sass → CSS compiler
markdown (Go) Go 1.9 MB ~2 ms Markdown → HTML
qrcode Go 3.6 MB ~3 ms QR code SVG generation
sanitize Go 4.3 MB ~22 ms HTML sanitization (bluemonday)
minify Go 7.1 MB ~0.2 ms CSS/JS/HTML/SVG minification
chroma Go 8.3 MB ~6 ms Syntax highlighting
nomnoml JS 2.5 MB ~21 ms Diagram rendering (nomnoml)
html-rewriter JS 3.3 MB ~0.8 ms HTML transformations (cheerio)
markdown-js JS 3.4 MB ~26 ms Markdown → HTML (markdown-it)
sanitize-js JS 3.9 MB ~2 ms HTML sanitization (sanitize-html)
enhance-ssr JS 3.9 MB ~4 ms Custom element SSR
katex JS 4.4 MB ~2 ms LaTeX math rendering
jsx-include JS 7.0 MB ~0.8 ms React JSX server-side rendering

Rust plugins are consistently the smallest — the entire ASCII art plugin with 5 embedded fonts compiles to just 256 KB. Go plugins include the GC and runtime, landing in the 2–8 MB range. JS plugins bundle their npm dependencies and the QuickJS runtime.

How It Works

FrankenWASM combines three key pieces:

  • FrankenPHP — embeds PHP in a Go process, giving us a Go server that serves PHP pages
  • Extism — a plugin framework for WebAssembly. Provides a simple "bytes in, bytes out" contract, with PDKs (Plugin Development Kits) for writing plugins in Go, Rust, JS, and more, and Host SDKs for loading and calling them from host languages
  • Wazero — a pure Go WebAssembly runtime with zero dependencies. Extism uses Wazero under the hood to compile and execute .wasm modules

Because FrankenPHP runs PHP inside Go, and Extism/Wazero run Wasm inside Go, the Go process becomes the bridge — a thin C extension connects PHP to Go, and Go connects to Wasm.

                 PHP                          Go Host                        Wasm Plugins
    ┌───────────────────────┐    ┌─────────────────────────────┐    ┌──────────────────────┐
    │                       │    │                             │    │                      │
    │  $p = new Wasm('md')  │    │  ┌─────────┐  ┌──────────┐  │    │  ┌────────────────┐  │
    │  $p->call('convert',  ├───►│  │ C ext   ├─►│ Go host  │  │    │  │ markdown.wasm  │  │
    │           $input)     │    │  │ (CGO)   │  │ (Extism) ├─–┼───►│  │ (Rust/Go/JS)   │  │
    │                       │◄───┤  │         │◄─┤          │  │    │  │                │  │
    │  // => "<h1>Hello</h1>"    │  └─────────┘  └──────────┘  │◄───┤  └────────────────┘  │
    │                       │    │                   │         │    │  ┌────────────────┐  │
    │                       │    │              ┌────┴─────┐   │    │  │ sanitize.wasm  │  │
    │                       │    │              │ Wazero   │   │    │  │ chroma.wasm    │  │
    │                       │    │              │ (Wasm VM)│   │    │  │ katex.wasm     │  │
    │                       │    │              └──────────┘   │    │  │ ...20 plugins  │  │
    │                       │    │                             │    │  └────────────────┘  │
    └───────────────────────┘    └─────────────────────────────┘    └──────────────────────┘
         FrankenPHP                    Extism + Wazero                  Extism PDK
      (PHP embedded in Go)         (plugin framework + VM)         (Go, Rust, JS SDKs)
  1. Plugins are standalone .wasm files built with the Extism PDK (Go, Rust, or JS)
  2. The host (main.go) discovers and loads all .wasm files from the plugins/ directory
  3. PHP code calls plugin functions through the FrankenPHP\Wasm class
  4. Arguments are automatically JSON-encoded by the PHP extension and decoded by the plugin

FrankenPHP Fork

FrankenWASM currently requires a fork of FrankenPHP that adds APIs not yet available in upstream FrankenPHP — hopefully that changes! The FrankenPHP\Wasm PHP class is implemented as a C extension that calls back into Go to reach the per-request WASM plugin registry — upstream FrankenPHP doesn't expose the thread plumbing to make that possible.

FrankenPHP already provides frankenphp.RegisterExtension(ptr) to register C zend_module_entry extensions — FrankenWASM uses this to register the FrankenPHP\Wasm PHP class.

The fork adds:

API Language Purpose
frankenphp.Thread(index) Go Retrieves a PHP thread by index, returning its *http.Request — which carries the request context where the WASM plugin registry is stored
frankenphp_thread_index() C Returns the current thread's index from C code, so PHP extension methods can call Thread(index) to get back into Go

The call chain looks like this:

PHP: $wasm->call('convert', $input)
  → C:  PHP_METHOD(Wasm, call)            // wasmplugin.c
  → C:  frankenphp_thread_index()         // gets current thread index
  → Go: go_wasm_call(threadIndex, ...)    // phpext.go (CGO export)
  → Go: frankenphp.Thread(threadIndex)    // retrieves the request context
  → Go: wasm.FromContext(ctx)             // gets the plugin registry
  → Go: registry.Call("convert", input)   // calls into Extism/WASM

The fork is referenced via a replace directive in go.mod:

replace github.com/dunglas/frankenphp v1.11.2 => ../frankenphp

Quick Start

Docker (recommended)

docker build -t frankenwasm .
docker run -p 8080:8080 frankenwasm

The multi-stage Dockerfile handles everything — PHP build, plugin compilation (Go, Rust, JS), FrankenPHP fork, and the host binary. Each stage is cached independently, so rebuilds are fast.

The PHP build stage uses static-php-cli which can download pre-built libraries (openssl, curl, etc.) from GitHub instead of compiling from source. This requires a GitHub token to avoid API rate limits:

GITHUB_TOKEN=$(gh auth token) docker build \
    --secret id=github_token,env=GITHUB_TOKEN \
    -t frankenwasm .

Without the token the build still works — it just compiles all libraries from source, which takes longer.

Open http://localhost:8080 to see the demos.

Local Build

Prerequisites

  • Go 1.26+
  • The FrankenPHP fork cloned as a sibling directory (../frankenphp)
  • For building plugins: Go, Rust/cargo, Node.js, and extism-js

Build & Run

make php        # Build PHP 8.3 (ZTS, embed) via static-php-cli (one-time)
make env        # Generate env.yaml with CGO flags from the PHP build
make plugins    # Build all .wasm plugins
make run        # Build the host binary + start the server on :8080

The PHP build is cached in build/.php/ — subsequent runs skip the build if libphp.a exists. To rebuild PHP from scratch:

make php-clean  # Remove cached downloads and build artifacts
make php        # Rebuild
make env        # Regenerate env.yaml

Manual Setup

If you prefer to use your own PHP build, create an env.yaml manually:

HOME: "/Users/you"
GOPATH: "/Users/you/go"
GOFLAGS: "-tags=nowatcher"
CGO_ENABLED: "1"
CGO_CFLAGS: "-I/path/to/php/include ..."
CGO_CPPFLAGS: "-I/path/to/php/include ..."
CGO_LDFLAGS: "-L/path/to/php/lib -lphp ..."

The CGO flags must point to your PHP build's include headers and libraries. PHP must be built with ZTS (--enable-zts) and embed (--enable-embed).

GoLand

Install the EnvFile plugin, then in your Run Configuration enable EnvFile and add env.yaml to load the CGO flags automatically.

Environment Variables

Variable Default Description
FRANKENWASM_PLUGIN_DIR plugins Directory to scan for .wasm files
FRANKENWASM_DOC_ROOT examples PHP document root directory
FRANKENWASM_PORT 8080 HTTP listen port
FRANKENWASM_THREADS 2 Number of PHP threads

PHP API

Static Methods

use FrankenPHP\Wasm;

// List all loaded plugin names
$plugins = Wasm::list();
// => ['markdown', 'chroma', 'katex', ...]

// Get metadata (name, file size) for all plugins
$metadata = Wasm::metadata();
// => [['name' => 'markdown', 'file_size' => 1234567], ...]

Instance Methods

use FrankenPHP\Wasm;

// Create an instance for a specific plugin
$md = new Wasm('markdown');

// Call a function — args are auto JSON-encoded
$html = $md->call('convert', '# Hello World');

// Call with structured args
$chroma = new Wasm('chroma');
$highlighted = $chroma->call('transform', [
    'code' => '<?php echo "hi";',
    'lang' => 'php',
]);

Data Exchange

PHP data in, PHP data out — arguments are automatically JSON-encoded, and return values are JSON-decoded back to PHP arrays/values. If the plugin returns a non-JSON string, it's passed through as-is. Anything you can json_encode() works as input.

Writing Plugins

Plugins use the Extism PDK to read input and write output. Each exported function receives input as bytes and returns output as bytes.

See plugins/ for complete examples in all three languages, and examples/ for the demo pages that exercise them.

Go

package main

import (
    "github.com/extism/go-pdk"
)

//go:wasmexport myfunction
func myfunction() int32 {
    input := pdk.Input()
    // ... process ...
    pdk.Output(result)
    return 0
}

Build: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -tags std -o plugin.wasm

Rust

use extism_pdk::*;

#[plugin_fn]
pub fn myfunction(input: String) -> FnResult<String> {
    // ... process ...
    Ok(result)
}

Build: cargo build --target wasm32-wasip1 --release

JavaScript

function myfunction() {
    const input = Host.inputString();
    // ... process ...
    Host.outputString(result);
}

module.exports = { myfunction };

Build: node esbuild.js && extism-js dist/index.js -i src/index.d.ts -o plugin.wasm

Project Structure

frankenwasm/
├── main.go              # HTTP server, plugin discovery, FrankenPHP init
├── wasm/                # Plugin manager, registry, context handling
│   ├── manager.go       # Load/instantiate plugins via Extism SDK
│   ├── registry.go      # Per-request plugin instance registry
│   ├── metadata.go      # Plugin metadata types
│   └── context.go       # Request context helpers
├── phpext/              # C + Go PHP extension (FrankenPHP\Wasm class)
│   ├── phpext.go        # Go exports called from C
│   ├── wasmplugin.c     # PHP method implementations
│   └── wasmplugin.h     # PHP method declarations
├── plugins/             # Plugin source code + built .wasm files
│   ├── markdown-go/     # Go: Markdown → HTML
│   ├── markdown-rs/     # Rust: Markdown → HTML
│   ├── markdown-js/     # JS: Markdown → HTML (markdown-it)
│   ├── chroma/          # Go: Syntax highlighting
│   ├── minify/          # Go: CSS/JS/HTML/SVG minification
│   ├── jsonpath-rs/     # Rust: JSONPath queries
│   ├── toml-rs/         # Rust: TOML ↔ JSON conversion
│   ├── sanitize/        # Go: HTML sanitization (bluemonday)
│   ├── sanitize-rs/     # Rust: HTML sanitization (ammonia)
│   ├── sanitize-js/     # JS: HTML sanitization (sanitize-html)
│   ├── enhance-ssr/     # JS: Custom element SSR (@enhance/ssr)
│   ├── jsx-include/     # JS: React JSX server-side rendering
│   ├── html-rewriter/   # JS: HTML transformations (cheerio)
│   ├── qrcode/          # Go: QR code SVG generation
│   ├── katex/           # JS: LaTeX math rendering
│   ├── nomnoml/         # JS: Diagram rendering (nomnoml)
│   ├── scss-rs/         # Rust: SCSS/Sass → CSS compiler (grass)
│   ├── blurhash-rs/     # Rust: Blur hash encoding/decoding
│   ├── ascii-rs/        # Rust: FIGlet-style ASCII art banners
│   └── langdetect-rs/   # Rust: Language detection (whatlang)
├── examples/            # PHP demo pages
├── build/
│   └── php/
│       └── Makefile     # PHP build via static-php-cli (ZTS + embed)
└── Makefile             # Build targets

License

Code is MIT — see LICENSE.md. Talk material is licensed under CC BY 4.0 — free to share and adapt with attribution.

About

WebAssembly plugins for PHP powered by FrankenPHP and Extism

Topics

Resources

License

Stars

Watchers

Forks

Contributors