You’ve written PHP for years. Maybe a decade. You know about opcache, you’ve read about the Zend VM, you’ve probably even peeked at php-src on GitHub once or twice. But you’ve never actually built a C extension.
I hadn’t either. Then I had an idea: what if I could replace a PHP function at runtime — swap it out for a different implementation? Not monkey-patching, not runkit, not uopz. A proper C extension that manipulates the function table directly.
How hard could it be?
This is the story of finding out. In this first article, we’ll go from zero to a PHP extension that loads, shows up in phpinfo(), and exposes a PHP function that does absolutely nothing useful. But it compiles, it loads, and it’s ours.
The Setup: Docker or Bust
You don’t need PHP compiled from source on your laptop. You need Docker. That’s it.
The build environment is a Debian container with PHP compiled with --enable-debug (debug symbols for GDB, which we will need later) and the standard phpize toolchain.
docker/Dockerfile.debian:
FROM debian:bookworm-slim
WORKDIR /usr/src
ENV PHPIZE_DEPS="autoconf dpkg-dev dpkg file g++ gcc libc-dev make pkgconf re2c"
RUN apt-get update -y \
&& apt-get install -y curl gdb valgrind ${PHPIZE_DEPS} \
libxml2-dev libsqlite3-dev zlib1g-dev
ARG PHP_VERSION
ENV PHP_URL="https://www.php.net/distributions/php-${PHP_VERSION}.tar.xz"
ENV CFLAGS="-ggdb3"
RUN curl -fsSL -o php.tar.xz "$PHP_URL" && tar -xf php.tar.xz && rm php.tar.xz
ARG PHP_CONFIG_OPTS="--enable-debug --with-pear --with-zlib"
RUN cd php-${PHP_VERSION} \
&& ./buildconf && ./configure ${PHP_CONFIG_OPTS} \
&& make -j $(nproc) && make install
WORKDIR /usr/src/myapp
docker-compose.yaml mounts our extension source into the container:
services:
debian:
build:
context: docker
dockerfile: Dockerfile.debian
args:
PHP_VERSION: ${PHP_VERSION:-8.3.10}
volumes:
- ./ext:/usr/src/myapp
Makefile wraps the common commands:
build-image:
PHP_VERSION=$(PHP_VERSION) docker compose build debian
build:
docker compose run debian ./build.sh
test:
docker compose run debian make test
shell:
docker compose run debian bash
make build-image once, then make build to compile.
ext/build.sh — the build script is trivial:
#!/bin/bash
set -e
phpize
./configure --enable-funswap
make clean 2>/dev/null || true
make -j$(nproc)
phpize generates the configure script from our config.m4. ./configure checks for PHP and creates the Makefile. make compiles. That’s the loop: edit C, make build, test.
The Minimum Viable Extension
A PHP extension needs four things to exist:
- A header file declaring the module entry
- A C source file with the module entry struct and lifecycle functions
- A config.m4 telling the build system what to compile
- A build script to run phpize and make
Let’s start with the header.
php_funswap.h — the smallest possible extension header:
#ifndef PHP_FUNSWAP_H
#define PHP_FUNSWAP_H
#include "php.h"
extern zend_module_entry funswap_module_entry;
#define phpext_funswap_ptr &funswap_module_entry
#define PHP_FUNSWAP_VERSION "0.1.0"
#if defined(ZTS) && defined(COMPILE_DL_FUNSWAP)
ZEND_TSRMLS_CACHE_EXTERN()
#endif
#endif
The zend_module_entry is the heart — it’s a struct that tells PHP everything about your extension: its name, version, which functions to call at startup and shutdown, and what PHP functions it exposes.
The ZTS/COMPILE_DL block is thread-safety boilerplate. You’ll see it in every extension. Copy it, don’t think about it.
Now the main source file.
funswap.c — this is where everything lives. Let’s build it piece by piece, starting with the includes:
/* funswap.c */
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "php.h"
#include "ext/standard/info.h"
#include "php_funswap.h"
#include "funswap_arginfo.h"
#include "zend_closures.h"
These pull in the PHP API, the info API (for phpinfo), our header, and the auto-generated argument info. zend_closures.h gives us zend_ce_closure which we need to accept Closure parameters.
Still in funswap.c — the module lifecycle. MINIT (Module Init) runs once when the extension loads:
/* funswap.c — continued */
PHP_MINIT_FUNCTION(funswap) {
#if defined(ZTS) && defined(COMPILE_DL_FUNSWAP)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
return SUCCESS;
}
Right now it does nothing. Later we’ll initialize data structures and register things here. For now: thread-safety boilerplate, return SUCCESS.
Still in funswap.c — the phpinfo function, shows up in php -i and phpinfo():
/* funswap.c — continued */
PHP_MINFO_FUNCTION(funswap) {
php_info_print_table_start();
php_info_print_table_row(2, "funswap support", "enabled");
php_info_print_table_row(2, "funswap version", PHP_FUNSWAP_VERSION);
php_info_print_table_end();
}
And the final piece of funswap.c — the module entry struct that ties it all together:
/* funswap.c — continued */
zend_module_entry funswap_module_entry = {
STANDARD_MODULE_HEADER,
"funswap", /* extension name */
ext_functions, /* function table (defined in arginfo) */
PHP_MINIT(funswap), /* module init */
NULL, /* module shutdown */
NULL, /* request init */
NULL, /* request shutdown */
PHP_MINFO(funswap), /* phpinfo */
PHP_FUNSWAP_VERSION,
STANDARD_MODULE_PROPERTIES,
};
#ifdef COMPILE_DL_FUNSWAP
#ifdef ZTS
ZEND_TSRMLS_CACHE_DEFINE()
#endif
ZEND_GET_MODULE(funswap)
#endif
The COMPILE_DL_FUNSWAP block at the bottom is what makes this a dynamically loadable extension (a .so file you load with extension=funswap.so). Without it, the extension would need to be compiled into PHP itself.
Adding a PHP Function
Our extension exposes one function: \FunSwap\replace(string $function, \Closure $replacement): bool. To register it, PHP needs two things: a stub (the PHP signature) and the C implementation.
The Stub
funswap.stub.php — the PHP signature for our function:
<?php
/** @generate-class-entries */
namespace FunSwap;
function replace(string $function, \Closure $replacement): bool {}
This looks like a PHP file with an empty function body. That’s exactly what it is. PHP’s gen_stub.php tool (shipped in php-src/build/) can parse this to generate the arginfo header automatically:
php php-src/build/gen_stub.php funswap.stub.php
For a real extension, use it — it keeps the stub and arginfo in sync and handles edge cases you don’t want to think about. But for this series, I’m writing the arginfo by hand so we can see exactly what it contains:
The Arginfo
funswap_arginfo.h — type information for the PHP engine:
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(
arginfo_FunSwap_replace, 0, 2, _IS_BOOL, 0)
ZEND_ARG_TYPE_INFO(0, function, IS_STRING, 0)
ZEND_ARG_OBJ_INFO(0, replacement, Closure, 0)
ZEND_END_ARG_INFO()
ZEND_FUNCTION(FunSwap_replace);
static const zend_function_entry ext_functions[] = {
ZEND_NS_FALIAS("FunSwap", replace, FunSwap_replace,
arginfo_FunSwap_replace)
ZEND_FE_END
};
This is telling PHP: “there’s a function called replace in the FunSwap namespace. It’s implemented by the C function FunSwap_replace. It takes 2 required arguments: a string and a Closure. It returns bool.”
ZEND_NS_FALIAS registers a namespaced function alias. ZEND_FE_END terminates the function list. The ext_functions array is referenced by our zend_module_entry.
The Implementation
Back in funswap.c — the actual C function (add this before the MINIT function):
/* funswap.c — the replace() implementation */
ZEND_FUNCTION(FunSwap_replace) {
zend_string *function_name;
zval *replacement;
ZEND_PARSE_PARAMETERS_START(2, 2)
Z_PARAM_STR(function_name)
Z_PARAM_OBJECT_OF_CLASS(replacement, zend_ce_closure)
ZEND_PARSE_PARAMETERS_END();
/* TODO: actual replacement logic */
RETURN_TRUE;
}
ZEND_PARSE_PARAMETERS_START/END is PHP’s parameter parsing API. It validates types and extracts C values from PHP zvals. Z_PARAM_STR extracts a zend_string*, Z_PARAM_OBJECT_OF_CLASS extracts a zval that must be an instance of zend_ce_closure (PHP’s \Closure class).
If you pass wrong types, PHP throws a TypeError automatically. You don’t handle it.
Right now the function just returns true. The interesting part comes in Article 2.
The Build Config
config.m4 — tells the build system what to compile:
PHP_ARG_ENABLE([funswap],
[whether to enable funswap support],
[AS_HELP_STRING([--enable-funswap],
[Enable funswap support])],
[no])
if test "$PHP_FUNSWAP" != "no"; then
AC_DEFINE(HAVE_FUNSWAP, 1, [ Have funswap support ])
PHP_NEW_EXTENSION(funswap, funswap.c, $ext_shared)
fi
config.m4 is autoconf. PHP_NEW_EXTENSION lists the source files to compile. When we add more .c files later, they go here.
Does It Work?
$ make build
Build complete.
$ docker compose run debian php -d extension=modules/funswap.so -r '
echo "loaded\n";
var_dump(\FunSwap\replace("strlen", function() { return 42; }));
'
loaded
bool(true)
It loads. It runs. replace() accepts a string and a Closure, returns true. strlen still works normally because we haven’t touched it yet.
Let’s also check phpinfo:
$ docker compose run debian php -d extension=modules/funswap.so -i | grep funswap
funswap support => enabled
funswap version => 0.1.0
We exist.
The Full Picture
At this point, your directory should look like this:
funswap/
├── docker/
│ └── Dockerfile.debian
├── docker-compose.yaml
├── Makefile
└── ext/
├── build.sh
├── config.m4
├── funswap.c
├── funswap.stub.php
├── funswap_arginfo.h
└── php_funswap.h
And your complete funswap.c should look like this:
/* funswap.c */
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "php.h"
#include "ext/standard/info.h"
#include "php_funswap.h"
#include "funswap_arginfo.h"
#include "zend_closures.h"
/* --- PHP_FUNCTION(replace) --- */
ZEND_FUNCTION(FunSwap_replace) {
zend_string *function_name;
zval *replacement;
ZEND_PARSE_PARAMETERS_START(2, 2)
Z_PARAM_STR(function_name)
Z_PARAM_OBJECT_OF_CLASS(replacement, zend_ce_closure)
ZEND_PARSE_PARAMETERS_END();
/* TODO: actual replacement logic */
RETURN_TRUE;
}
/* --- Module lifecycle --- */
PHP_MINIT_FUNCTION(funswap) {
#if defined(ZTS) && defined(COMPILE_DL_FUNSWAP)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
return SUCCESS;
}
PHP_MINFO_FUNCTION(funswap) {
php_info_print_table_start();
php_info_print_table_row(2, "funswap support", "enabled");
php_info_print_table_row(2, "funswap version", PHP_FUNSWAP_VERSION);
php_info_print_table_end();
}
/* --- Module entry --- */
zend_module_entry funswap_module_entry = {
STANDARD_MODULE_HEADER,
"funswap",
ext_functions,
PHP_MINIT(funswap),
NULL,
NULL,
NULL,
PHP_MINFO(funswap),
PHP_FUNSWAP_VERSION,
STANDARD_MODULE_PROPERTIES,
};
#ifdef COMPILE_DL_FUNSWAP
#ifdef ZTS
ZEND_TSRMLS_CACHE_DEFINE()
#endif
ZEND_GET_MODULE(funswap)
#endif
To be continued…
In the next article, we’ll make replace() actually do something: swap a function in PHP’s function table. For internal functions like strlen, it’ll work on the first try. For user functions? That’s where it gets interesting.
Leave a Reply