Wasm Labs

Compiling PHP to WebAssembly

By Rafael Fernández
At 2023 / 07 10 mins reading

PHP is one of the most popular programming languages in the world. It powers the 77.4% of all websites on the web according to W3Techs. Regardless of these numbers and their accuracy, there is undoubtable interest today in running PHP on the server. But not only the server.

We will show you that it's possible to run PHP code on the frontend and other environments, and discover some neat use cases for it. All of this is possible thanks to WebAssembly (Wasm), a binary instruction format that runs on top of a virtual machine. As many other technologies, it started in the browser context, and permeated other environments, due in part to its unchallengeable utility.

Stay with us! In this article we will cover different uses cases for PHP, how you can compile it to WebAssembly, and what are the gotchas and issues in this process.

Let's start from the beginning. What does it mean to "compile PHP to WebAssembly", and why is it of interest?

PHP to WebAssembly

PHP is, in general, an interpreted language. Although there have been attempts to transpile PHP code to other intermediate languages, PHP has been largely used as an interpreted language and this is still the case today.

When we talk about compiling PHP to WebAssembly, we are referring to is compiling the official PHP interpreter. Wasm is a compilation target like MacOS / amd64 or Linux / aarch64. However, instead of of targeting a specific architecture and operating system, the binary runs on top of a Virtual Machine.

When we talk about compiling PHP to WebAssembly, what we are referring to is compiling the official PHP interpreter to WebAssembly.

This approach opens new opportunities for PHP, as you can run PHP applications in any environment that has a WebAssembly virtual machine. This ranges from browsers, servers to even more heterogeneous environments like mobile phones and embedded devices.

As a result of this diversity, there is not a single WebAssembly target. There are multiple flavors that suits better to different environments.

WebAssembly flavors

You can compile programs to multiple flavors. The way in which we define target systems is by using target triples like x86_64-linux-musl and aarch64-apple-darwin. In WebAssembly terms, there are 3 main flavors:

  • wasm32-unknown-unknown
  • wasm32-unknown-emscripten
  • wasm32-wasi

wasm32 is common to all of them and expresses the CPU architecture. Given that we are building the PHP interpreter to WebAssembly this will be common to all our flavors. When 64 bits WebAssembly is released, this will become wasm64.

Let's dig into why these flavors are required.

  • wasm32-unknown-unknown does not have any assumptions about the environment. Modules are fully isolated and don't include any extra feature outside of the official WebAssembly specification. To interact with the host that runs it, modules can import and export methods. For example, a module can import a log function and export a run one.

  • wasm32-unknown-emscripten is the target from the Emscripten toolchain. It primarily targets the browser environment, or an environment that has a JavaScript engine available, such as NodeJS. It is common for this target to produce an accompanying JavaScript file that loads the WebAssembly module.

  • wasm32-wasi targets WebAssembly runtimes that are compatible with the WASI specification. WASI stands for WebAssembly System Interface. It offers a set of features that are common for server-side environments, like filesystems and sockets. Examples of WebAssembly runtimes that support WASI are Wasmtime, WasmEdge or Wazero.

We can summarize the previous paragraphs into the following table:

Target Standard Library (guest) Runtime (host)
wasm32-unknown-unknown None Any
wasm32-unknown-emscripten libc, libc++, SDL Browser, Node.js
wasm32-wasi wasi-libc (musl based) WASI-compliant runtime

Now that we understand what the options are when building a program to the WebAssembly targets, let's dive into the PHP specific case.

PHP Flavors

In the PHP port to WebAssembly we are only interested in the wasm32-unknown-emscripten and the wasm32-wasi targets.

The former will allow us to execute our PHP applications in browsers and JavaScript-enabled engines, without ever leaving the client side if we don't want to.

The latter will enable us to run PHP in any environment that can initialize a WASI-compliant WebAssembly runtime. For example:

  • A website that runs a full PHP application like WordPress Playground.
  • An Apache module that can serve HTTP requests with WebAssembly like mod_wasm.
  • Serverless applications like Wasm Workers Server.
  • Extending an existing application with PHP in a multi-platform safe sandbox.

What about third-party libraries?

An interpreted language doesn't usually boil the ocean and reimplement the whole world around us. It's a common approach to take advantage of third-party libraries that implement certain functionality. When compiling an interpreter we need to ask these kind of questions first. Do we want XML support? JSON? Regular Expressions? sqlite?

We have to compile all the extensions we have chosen to the same target, and statically link the interpreter with these libraries. At the time of writing there is no such thing as dynamic linking in WebAssembly. It is work in progress — it is called the Component Model —, but it's not in the scope of this article.

It is also very common for these interpreters to enable or to deactivate certain functionality features and extensions at configure time (the step prior to building). When building to WebAssembly, it is a good approach to deactivate all extensions first to reduce the number of required dependencies.

After we disabled all optional extensions, we can focus on building the interpreter; and come back to enabling more extensions afterwards.

Compilation steps

The naive and first approach when building the interpreter is to try to build it right away, using the toolchain that builds for our target. This is, emcc in the Emscripten case, or WASI SDK in the case of the wasm32-wasi target.

We are going to explore both toolchains with a very simple program. It gives a hint of how it works on a much bigger and complex project such as a language runtime.

Emscripten

Emscripten produces a combination of WebAssembly and JavaScript that can run in modern JavaScript runtimes like web browsers and Node.js.

Every syscall in C is handled by the Emscripten-generated JavaScript module. It emulates a POSIX environment and handles memory allocation, filesystem access, networking, and all other syscalls. Even though it is not feature complete and you can't, for example, fork() a process, it still makes a great foundation for WebAssembly development.

Let's try building a simple program that displays the PID of the running process:

#include <unistd.h>

int main(int argc, char **argv) {
printf("Hello, world! (PID: %d)\n", getpid());
return 0;
}

If we try to build this program without any arguments, we get:

$ emcc -o hello.js hello.c
$ ls
hello.c hello.js hello.wasm

That's it! The hello.wasm file is our C program compiled to WebAssembly, and hello.js is the runtime bridging C syscalls to JavaScript function calls. Let's see run our program using Node.js:

$ node hello.js
Hello, world! (PID: 42)

It worked — great! There's an open question here. How can we see that message if the WebAssembly printf() is isolated from the host?

This is where the JavaScript runtime comes in — it converted the formatted message from a C string to a JavaScript string and passed it to console.log(). Similarly, we can see a PID number even though the host system's process manager wasn't involved. Emscripten injected a simple getpid() handler that always returns 42.

WASI SDK

When targeting wasm32-wasi, using WASI SDK is not a hard requirement, but a very handy approach. The wasi-libc project gives us the ability to use C syscalls in our guest that will be mapped to WASI syscalls, implemented in the host runtime. The wasi-libc project does not replace the entire POSIX space, but gives us a good place to start.

In order to make it even easier to set up the clang compiler, already configured to use the wasi-libc C library, you can use wasi-sdk, which includes everything in a single place.

It is time to compile our simple program. Let's reuse the same PID example from the previous section:

#include <stdio.h>
#include <unistd.h>

int main(int argc, char **argv) {
printf("Hello, world! (PID: %d)\n", getpid());
return 0;
}

If we try to build this program without any arguments, we get:

$ clang -o program.wasm main.c
main.c:5:39: warning: 'getpid' is deprecated: WASI lacks process identifiers; to enable emulation of the `getpid` function using a placeholder value, which doesn't reflect the host PID of the program, compile with -D_WASI_EMULATED_GETPID and link with -lwasi-emulated-getpid [-Wdeprecated-declarations]
printf("Hello, world! (PID: %d)\n", getpid());
^
/usr/share/wasi-sysroot/include/unistd.h:155:16: note: 'getpid' has been explicitly marked deprecated here
__attribute__((__deprecated__(
^
1 warning generated.
wasm-ld: error: /tmp/main-7a61a9.o: undefined symbol: getpid
clang-15: error: linker command failed with exit code 1 (use -v to see invocation)

Although this fails, wasi-libc is pointing to the solution, so that it can emulate certain syscalls if explicitly requested.

$ clang -D_WASI_EMULATED_GETPID -Wl,-lwasi-emulated-getpid -o program.wasm main.c
$ wasmtime program.wasm
Hello, world! (PID: 42)

Now we can build our program, although the emulation in this case will return the same fixed number every single time. This syscall in this case never leaves the guest, it is emulated and returns a fixed number all within the guest boundary. Other syscalls are mapped in a different way to the host, and end up as a real syscall on the Operating System, all depending on the implementation of the host side of the WASI specification.

This is related to the hard design process of the WASI project, where it is necessary to weigh very well the balance between keeping existing programs functionality, while running in a very different environment isolated from the Operating System beneath.

As an example, the team designing WASI could have decided to add a getpid system call to WASI, and let WASI-compatible runtimes implement it, possibly just forwarding the call to the OS, as a real system call, but there is a very hard decision making of what concepts make sense to map to the WebAssembly guest, and how it can impact the WebAssembly security posture. It is very common to see very detailed and thoughtful discussions in the GitHub WASI family repositories for the rejection or postponing of many of the concepts and features that we take for granted in a POSIX environment.

The getpid() case was just an example. There are more subtle differences in other very common calls such as mmap() that might be hard to track. Let's dig a bit into those.

Common issues

Getting the program to compile — in this case, the interpreter — is just the first step. Then, there may be more gotchas to be found at run time.

The mmap() case

The WebAssembly Language Runtimes project provides ready to use WebAssembly binaries for PHP, Python and Ruby compiled to wasm32-wasi. While working on running Wordpress with the PHP interpreter, we found a couple of issues both on SQLite and PHP.

As part of the workstream getting popular PHP apps with their default dependencies running on wasm32-wasi we ported, released and published some common libraries that you can use today.

In both issues mentioned earlier, the underlying issue was that they were expecting aligned memory, what mmap provides to some extent. mmap wasi-libc's implementation relies on malloc, what will provide a 16-byte alignment. If this alignment is not enough, we would need to allocate another page (64 KiB in WebAssembly), what would be quite a lot of padding, with the accompanying memory waste; for every instantiation of PHP and SQLite.

mmap in the current wasi-libc implementation is also able to map files into memory but is not as feature complete and has not that many guarantees as the POSIX mmap. It can fit the bill for some use cases, but it's not its intention to be a 1:1 replacement of the original mmap.

If the implementation allows for it, a valid option is to disable mmap, and use something else.

In the PHP case, the Zend parser required a greater alignment than the one provided by malloc, and in this case we had to rely on aligned_alloc in order to fulfill Zend parser's requirements.

Networking support

Networking support is approached in completely different ways in the two environments we are evaluating in this article.

With wasm32-unknown-emscripten, the WebAssembly module is running in a JavaScript environment. It has access to networking functionality, although with some required tweaks. In the wasm32-wasi environment the WebAssembly module runs in an even more restricted environment where it is not able to create a socket.

Let's dig a bit more on the peculiarities of each environment.

Networking on Emscripten

In Emscripten builds, it is JavaScript's job to figure out what to do when the C code requests a raw TCP socket — Node.js can do that, but web browsers can't.

Therefore, Emscripten does not attempt to open a TCP socket at all — it emulates POSIX TCP sockets using WebSockets. The WebSockets client gets included in the build and tunnels the traffic to a server-side endpoint which then opens the requested TCP socket. That happens even in Node.js so that Emscripten doesn't have to maintain two separate code branches.

Emscripten provides a WebSocket Proxy Server that allows a web browser page to run full TCP & UDP connections, act as a server to accept incoming connections, and perform host name lookups and reverse lookups. However, using it is not recommended because all syscalls are proxied as separate WebSocket messages which can get quite slow.

This is why WordPress Playground applies a custom patch to the JavaScript module to only proxy a small subset of syscalls required to connect to a MySQL server. In particular, it proxies setsockopt() calls when the option is either SO_KEEPALIVE or TCP_NODELAY. That's it. Luckily, it aligns perfectly with the socket options supported by the Node.js net module that powers Playground's proxy.

There's another challenge: Many socket-related operations in C sockets are synchronous, but they are handled by an asynchronous WebSockets proxy. This is where Asyncify comes in. We'll get to it soon, but first let's discuss how networking works in WASI.

Networking on WASI

The WASI environment differs from the Emscripten one in that the runtime in which it is executed does not let the guest module to open outbound connections, or create new sockets on startup to receive inbound connections — even using creative solutions as we have just seen with WebSockets.

In WASI, the host creates a socket pair on behalf of the WebAssembly module, and forwards the socket file descriptors to the WebAssembly guest when required. From that point on, the WebAssembly guest is able to accept() connections normally. Similarly, the WebAssembly guest is not able to open outbound connections currently. There are specific solutions provided by certain runtimes that allow us to open outbound connections.

The WASI community is also working on a Sockets, HTTP and even gRPC proposals that will enrich WebAssembly modules networking capabilities.

Asyncify

If everything has gone well up to this point, we have an interpreter that is able to run PHP applications in a synchronous way. This is, it runs programs start to end, and cannot be interrupted in between.

However, there are environments that requires an asynchronous context. For example, the Wasm module requires to interact with asynchronous JavaScript in the browser (wasm32-unknown-emscripten).

So, what is Asyncify? Asyncify instruments an existing WebAssembly module in a way that its execution can be suspended and resumed.

Asyncify instruments an existing WebAssembly module in a way that its execution can be suspended and resumed.

It is also very relevant in wasm32-wasi environments, while some WebAssembly proposals are being finalized, such as the stack switching proposal. In this environment, asyncify serves as an escape hatch that allows us to implement non-linear program control flows, such as setjmp/longjmp emulation. This is very useful for example for implementing exceptions, fibers and similar alternative control flows.

With asyncify in this kind of environment, we are able to save some global state that we can use to get back to an existing execution state.

Synchronous and asynchronous code

In the Emscripten environment the flow control is handled by the JavaScript module, but Asyncify is crucial to interface between the synchronous syscalls used in C and the asynchronous APIs available in JavaScript. Take networking. Here's how PHP uses setsockopt() in its MySQL connector:

int result = setsockopt(socketd, IPPROTO_TCP, TCP_NODELAY, (char *) &flag, sizeof(int));

if (result == -1) {
ret = FAILURE;
}

Even though this short snippet is idiomatic C, there is no straightforward way of handling it JavaScript. C expects a synchronous return value, but JavaScript will only have it eventually once the WebSocket proxy replies.

So, how can it work?

Emscripten transforms all C functions triggering this synchronous-to-asynchronous delegation into a state machine that remembers the program state and unwinds the call stack. Then, the JavaScript event loop continues. Once the result is ready, the WebAssembly call stack is rewound function by function until the previous unwinding point is reached — from there, the synchronous execution continues as if nothing ever happened.

One downside of this approach is that Emscripten needs to know about every C function triggering asynchronous behaviors. Not just that, but it also needs to transform every function that can be on a call stack when the call is made.

Emscripten can autodetect such functions to make it easier for developers, but C programs using dynamic calls can trick Emscripten into overly greedy wrapping which significantly slows down the entire program. This is why WordPress Playground chose to list all such functions manually. The trade-off is that missing even a single function crashes the entire program, so Playground built developer tools to automate detecting and patching such cases.

In the future, Asyncify may get replaced by JSPI — a new standard proposition in the making. JSPI works by executing developer-specified WASM exports on a separate stack, putting that stack aside when an asynchronous call is made, and restoring it later. There is no rewinding/unwinding like with Asyncify. At the moment of writing this article, JSPI is an experimental feature available in the arm64 and x64 builds of V8 — so in Node.js and chromium-based browsers.

In other Asyncify use cases, such as the emulation of setjmp/longjmp, WebAssembly proposals such as the Stack Switching will allow us to not depend on Asyncify as the escape hatch to implement them and rely on the specification directly.

Conclusions

As we have seen, porting a program to WebAssembly is not a trivial process. Complex programs as interpreters can be very tricky. The program surface has an impact, and interpreted programs tend to have rich standard libraries that increase the number of dependencies and the number of third-party libraries to port.

We have detailed what are the different options and toolchains for building programs to WebAssembly and we have focused on two of them:

  • JavaScript-enabled runtimes such as the browser and Node.js.
  • WASI-enabled runtimes.

Then, we have seen how we can take on the task to compile an interpreter and provided some rough steps on how that process looks like for both toolchains. We have also described what are the key strategies to follow in order to compile our interpreter depending on the target.

We then got to describing some of the most important issues, and how to overcome them.

Do you want to stay up to date with WebAssembly and our projects?