PHP Extension Development — From the Zend Engine to a Loadable Shared Library
Get a conceptual, correctly-oriented understanding of what a PHP extension is and how one comes to exist: the Zend Engine API from an extension author's perspective, the MINIT/RINIT-style lifecycle hooks, the phpize/configure/make build pipeline, how a built extension gets loaded via php.ini, PECL as the community distribution channel, and a clear-headed comparison of when FFI is a better tool than writing a native extension, and vice versa.
Every PHP installation ships with dozens of extensions already -- json, pcre, mbstring, pdo -- and countless more exist as separate installable packages (redis, imagick, xdebug, swoole). All of them are built the same fundamental way: compiled C code, linked against PHP's own C headers, loaded directly into the PHP process where it runs as native machine code rather than interpreted PHP bytecode. This lesson does not turn you into a C extension author in one sitting -- that genuinely takes sustained, dedicated study of the Zend Engine's internals -- but it gives you the accurate mental model of what an extension actually is, how it's built and loaded, and crucially, when reaching for one is the right call versus when PHP 7.4+'s FFI extension lets you get 90% of the benefit with none of the build pipeline.
What a PHP Extension Actually Is
A PHP extension is a compiled shared library -- a .so file on Linux, a .dll on Windows -- that the PHP binary loads into its own process at startup and that adds new capability directly into the running engine: new functions, new classes, new resource types, new php.ini directives. Because the code is native machine code running in the SAME process as the Zend Engine itself, rather than PHP source being parsed and interpreted, an extension can do things pure userland PHP fundamentally cannot: hook directly into the engine's own compilation and execution machinery (this is exactly how Xdebug intercepts every function call for step-debugging, and how OPcache caches and serves precompiled bytecode), hold C-level state that persists across the process's whole lifetime rather than being torn down every request, or wrap an existing high-performance C library (libcurl for the curl extension, libsodium for sodium) with a thin, fast bridge rather than reimplementing its logic in PHP.
The vast majority of PHP code you will ever write calls INTO extensions constantly without ever writing one -- every array_*, every preg_*, every str_* function is implemented in C as part of a bundled extension, which is a large part of why they're fast relative to an equivalent hand-written PHP loop: there's no per-call PHP-bytecode interpretation overhead inside the function body itself, only at the boundary of calling it.
• An extension = a compiled shared library (.so/.dll) loaded directly into the PHP process. • Runs as native machine code, not interpreted PHP bytecode -- can hook the engine itself, hold process-lifetime state, wrap existing C libraries directly. • Nearly every built-in PHP function (array_*, preg_*, str_*) is itself implemented this way, inside a bundled extension.
The Zend Engine API From an Extension Author's View
At the center of the Zend Engine API is the zval -- PHP's tagged-union value container, the C-level structure that represents every PHP value (an int, a string, an array, an object) uniformly enough that the engine's opcodes can operate on any of them generically. An extension author works with zvals constantly: reading arguments passed from PHP code, constructing return values, and manipulating arrays and objects at the C level using engine-provided macros and functions rather than raw struct field access, both because the exact struct layout has changed across PHP major versions (most dramatically between PHP 5 and PHP 7) and because the engine's reference-counting and memory-management invariants have to be respected precisely.
Extensions register what they provide through a zend_module_entry structure -- a table the engine reads at startup listing the extension's name, version, its function table (built from PHP_FE macros pairing each PHP_FUNCTION implementation with an arginfo structure describing its parameters and return type for reflection and type-checking), and pointers to its lifecycle hook functions. Memory inside an extension is normally allocated through the Zend Memory Manager (emalloc/efree, and related array/string helpers) rather than the C standard library's malloc/free directly -- ZMM-allocated memory tied to a request is automatically reclaimed at request end even if an extension forgets to free it explicitly, which is a safety net worth understanding correctly rather than relying on as a substitute for correct cleanup.
• zval -- the tagged-union C structure representing every PHP value; extensions read/write these via engine macros, not raw struct access. • zend_module_entry -- the table an extension fills in to register its name, functions (via PHP_FE + arginfo), classes, and lifecycle hooks. • emalloc/efree (Zend Memory Manager) -- request-scoped allocation with an automatic safety-net reclaim at request end, distinct from raw malloc/free. • The exact API surface is versioned and has changed across PHP major releases -- an extension written against PHP 7's internals needs real porting work for PHP 8, not just a recompile.
Extension Lifecycle: MINIT, MSHUTDOWN, RINIT, RSHUTDOWN
Every extension can implement up to four lifecycle hooks, and understanding what each one runs FOR is the single most important conceptual piece of extension development. PHP_MINIT_FUNCTION (Module Init) runs exactly ONCE, when the extension is first loaded into a PHP process -- this is where functions, classes, and php.ini directive definitions get registered with the engine, because that registration only needs to happen once for the lifetime of the process, not once per request. PHP_MSHUTDOWN_FUNCTION (Module Shutdown) is its mirror image, running once as the process is shutting down, for any process-lifetime cleanup.
PHP_RINIT_FUNCTION (Request Init) runs at the START of EVERY single request the process handles, and PHP_RSHUTDOWN_FUNCTION (Request Shutdown) runs at the end of every one -- this pair is exactly what gives a traditional PHP-FPM request its clean-slate guarantee at the extension level: any request-scoped globals an extension maintains get reset in RINIT and cleaned up in RSHUTDOWN, every single request, regardless of what the previous request left behind. This is worth connecting directly back to the long-running-process lesson: an extension author reasoning about a request-scoped global has to think about exactly the same MINIT-vs-RINIT distinction that application code reasoning about static properties has to think about in a long-running worker -- 'does this live for the whole process, or does it get reset per unit of work?' is the same question at both layers.
• MINIT -- runs once per process; register functions, classes, ini directives here. • MSHUTDOWN -- mirrors MINIT, runs once at process shutdown. • RINIT -- runs at the START of every request; reset request-scoped globals here. • RSHUTDOWN -- runs at the END of every request; free anything request-scoped here. • This MINIT-vs-RINIT distinction is the C-level mirror of the process-lifetime-vs-request-lifetime distinction from the Long-Running PHP Applications lesson.
Building From Source: phpize, configure, make, and Loading It
A typical extension's source directory contains a config.m4 file (an autoconf-style script describing the extension's name, its source files, and any external libraries it needs to link against) alongside the .c/.h source. phpize -- itself a small tool shipped with PHP's development headers (the php-dev/php-devel OS package) -- reads config.m4 and generates a full configure script tailored to the PHP installation it's run against. Running ./configure (with flags like --enable-myext or --with-somelib=/path) then detects the installed PHP's headers and library paths and writes out a Makefile.
make compiles the .c sources into the shared library, and make install copies the resulting .so into PHP's configured extension_dir (visible via php -i | grep extension_dir). None of that makes the extension active, though -- PHP only loads extensions explicitly listed via an extension= directive in php.ini (or a file under a conf.d/-style include directory most distributions use for per-extension .ini snippets), and the process (php-fpm, php-cli, or the web server) needs to be restarted to pick the newly enabled extension up.
• phpize -- generates a configure script from config.m4, using the installed PHP's dev headers. • ./configure --enable-myext -- detects PHP headers/libs, writes a Makefile. • make && make install -- compiles the .c sources into a .so and copies it into extension_dir. • extension=myext (or myext.so) in php.ini -- the step that actually activates a built extension; requires a process restart.
PECL, Extension Configuration, and the PHP/C Boundary
PECL (the PHP Extension Community Library) is the repository and pecl install <name> tooling that automates the phpize/configure/make/install sequence above for community-maintained extensions (redis, imagick, and many others are commonly installed this way) -- distinct from its sibling PEAR, a pure-PHP package repository that Composer has largely superseded for userland code, while PECL remains the standard distribution point for compiled extensions specifically. An extension can also define its own php.ini directives (opcache.memory_consumption, redis.session.locking_enabled are examples from extensions covered elsewhere in this course), registered via PHP_INI_ENTRY-style macros inside MINIT and read back at runtime through helpers like INI_INT().
'The PHP/C boundary' refers to the conversion point where a PHP-level call crosses into native code -- parsing incoming zval arguments into C types (via zend_parse_parameters), and converting a C-level result back into a zval to return. That crossing has real, measurable per-call overhead (argument parsing, reference-count bookkeeping), which is exactly why a hot loop written natively in C as a single extension function call can dramatically outperform the equivalent PHP loop that crosses this same boundary once PER ITERATION by calling an extension function from inside a foreach.
• PECL -- the standard repository/installer for community-maintained compiled extensions (pecl install <name>). • PEAR -- PECL's pure-PHP sibling repository, largely superseded by Composer for userland packages. • Extension-specific php.ini directives are registered by the extension itself in MINIT via PHP_INI_ENTRY-style macros. • Every PHP-to-C call crosses a real overhead boundary (zend_parse_parameters, refcounting) -- crossing it once per hot-loop iteration is far more expensive than crossing it once for a whole batch operation.
FFI vs Writing a Native Extension
FFI (Foreign Function Interface, PHP 7.4+) lets ordinary userland PHP code call functions and read/write structs in an ALREADY-COMPILED C shared library at runtime, described via a C header string parsed on the fly (FFI::cdef(), or FFI::load() against a real .h file) -- with zero PHP compilation step and no phpize/configure/make pipeline at all. It is the right tool when the goal is calling a handful of functions from an existing C library (a vendor-supplied .so, a system library like libyaml) from a script or application without setting up a native build toolchain, and it can even be iterated on interactively, unlike an extension which requires a full rebuild-and-restart cycle for every change.
Writing a full native extension remains the better choice when any of the following actually apply: you need genuinely new engine-level behavior (new syntax semantics, hooks into opcode execution or compilation, the kind of thing only OPcache- or Xdebug-style engine integration can do), extremely hot-path performance where FFI's per-call marshaling overhead itself becomes the bottleneck, or you're shipping to an environment where end users cannot be relied on to have ffi.enable turned on (it defaults to CLI/preload-only for web requests specifically because unrestricted FFI is close to an arbitrary-native-code-execution capability, a serious concern in a shared-hosting or multi-tenant web context). Put simply: reach for FFI to call existing compiled C from PHP with no build step; write a full extension when you need to become part of the PHP runtime itself.
• FFI -- call an already-compiled C library from plain PHP at runtime, no compilation of PHP itself, usable from CLI without a build toolchain. • Native extension -- required for genuine engine-level hooks, extreme hot-path performance, or environments where FFI can't be relied on to be enabled. • ffi.enable defaults to CLI/preload-only for web requests -- a deliberate security boundary, since unrestricted FFI is near arbitrary native code execution. • Rule of thumb: FFI = call existing C; extension = become part of the runtime.
Code Examples
PHP_FUNCTION(myext_hello) is the C implementation that becomes callable from PHP as myext_hello('World'); ZEND_PARSE_PARAMETERS_START/Z_PARAM_STR pulls the single string argument out of the zval argument stack with type checking, and RETURN_STR hands a newly built zend_string back as the return value. The zend_module_entry struct at the bottom is what the engine actually reads at load time -- it wires the function table and the MINIT/RINIT hooks together into the one structure PHP needs to activate this extension.
PHP_FE(myext_hello, NULL) This is the autoconf-style description phpize consumes: PHP_ARG_ENABLE declares the --enable-myext configure flag, and PHP_NEW_EXTENSION tells the generated build system which.c files make up the extension. Nothing here compiles anything by itself -- it only describes how./configure should behave once phpize has generated it.
Each command maps directly onto a stage from the lesson: phpize and ./configure prepare the build against the currently installed PHP; make/make install produce and place the .so; the extension= line in an ini file is the step that actually activates it (a built-but-unlisted .so does nothing); and the process restart plus a final php -m check confirms the whole pipeline worked end to end.
FFI::cdef() takes a minimal C declaration string (just the function signatures actually being called, not the whole real header) and a shared library name, and returns an object whose methods call directly into that already-compiled native library. This is precisely the 'call existing C, no build step' use case from this lesson -- there is no config.m4, no phpize, no restart required; the FFI binding is created and used entirely within a normal PHP script.
FFI::cdef("double sqrt(double x);", "libm.so.6");
Common mistake: Reaching for FFI (or a full extension) to call a math function like sqrt() that PHP already exposes natively as sqrt() -- FFI is for calling libraries PHP does NOT already wrap, not a general substitute for built-in functions.
/* myext.c -- minimal skeleton, illustrative rather than a complete build */
#include "php.h"
#include "ext/standard/info.h"
PHP_FUNCTION(myext_hello)
{
zend_string *name;
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_STR(name)
ZEND_PARSE_PARAMETERS_END();
zend_string *greeting = zend_strpprintf(0, "Hello, %s!", ZSTR_VAL(name));
RETURN_STR(greeting);
}
PHP_MINIT_FUNCTION(myext)
{
/* Runs ONCE per process: register functions/classes/ini directives here. */
return SUCCESS;
}
PHP_RINIT_FUNCTION(myext)
{
/* Runs at the start of EVERY request: reset request-scoped globals here. */
return SUCCESS;
}
static const zend_function_entry myext_functions[] = {
PHP_FE(myext_hello, NULL)
PHP_FE_END
};
zend_module_entry myext_module_entry = {
STANDARD_MODULE_HEADER,
"myext",
myext_functions,
PHP_MINIT(myext),
NULL, /* MSHUTDOWN */
PHP_RINIT(myext),
NULL, /* RSHUTDOWN */
NULL, /* MINFO */
"0.1.0",
STANDARD_MODULE_PROPERTIES
};
ZEND_GET_MODULE(myext)dnl config.m4 -- read by phpize to generate ./configure for this extension
PHP_ARG_ENABLE([myext],
[whether to enable myext support],
[AS_HELP_STRING([--enable-myext], [Enable myext support])],
[no])
if test "$PHP_MYEXT" != "no"; then
PHP_NEW_EXTENSION(myext, myext.c, $ext_shared)
fi# From inside the extension's source directory:
phpize # generates ./configure from config.m4
./configure --enable-myext # detects this PHP's headers/libs, writes Makefile
make # compiles myext.c into modules/myext.so
sudo make install # copies myext.so into PHP's extension_dir
# Find where it needs to be enabled:
php -i | grep extension_dir
# Enable it (e.g. in /etc/php/8.3/mods-available/myext.ini, then symlink into
# conf.d, or directly append to php.ini):
echo "extension=myext" | sudo tee /etc/php/8.3/mods-available/myext.ini
# Restart the process that needs to pick it up:
sudo systemctl restart php8.3-fpm
# Confirm it loaded:
php -m | grep myext<?php
declare(strict_types=1);
// Describe just the bits of libm we need, then bind them to the real
// shared library already installed on the system -- no compilation of
// PHP itself, no phpize/configure/make pipeline.
$math = FFI::cdef(
"double sqrt(double x); double pow(double base, double exp);",
"libm.so.6"
);
$result = $math->sqrt(144.0);
echo $result . PHP_EOL; // 12.0
$squared = $math->pow(7.0, 2.0);
echo $squared . PHP_EOL; // 49.0Important Rules
MINIT runs once per process -- register functions/classes/ini directives there, never per-request setup.
RINIT/RSHUTDOWN run every request -- any request-scoped extension global must be reset there, mirroring the request-vs-process distinction from long-running PHP applications.
A built .so does nothing until it's listed via extension= in php.ini and the process is restarted.
Every PHP-to-C call has real per-call overhead -- design extension APIs for batch operations, not one call per loop iteration.
Prefer FFI over writing a full extension unless you specifically need engine-level hooks, extreme hot-path performance, or ffi.enable can't be relied on in your deployment target.
Why This Matters in Production
Most PHP engineers will never write a native extension, but understanding this lifecycle correctly pays off constantly: knowing that MINIT runs once explains why an extension's php.ini settings need a process restart to take effect (the same reason application-level config caching needs a restart or explicit invalidation); knowing FFI exists and where its security boundary sits (ffi.enable defaults to off for web requests) prevents either dismissing FFI as unusable or, worse, enabling it broadly on a public-facing web server without understanding the native-code-execution surface that opens up.
sudo make install echo "extension=myext" | sudo tee /etc/php/8.3/mods-available/myext.ini sudo systemctl restart php8.3-fpm php -m | grep myext # -> myext make install only copies the compiled.so into extension_dir on disk -- it does not register it as active. PHP only loads extensions explicitly listed via an extension= directive, and only picks up that change on the next process restart.
[multiple-choice] What is the key difference between an extension's MINIT and RINIT hooks? A. They are aliases for the same hook B. MINIT runs once per process at extension load; RINIT runs at the start of every single request C. MINIT runs per request; RINIT runs once per process D. Both run only when the extension is being uninstalled
B. MINIT runs once per process at extension load; RINIT runs at the start of every single request — MINIT (Module Init) fires once when the extension is loaded into the process and is where functions/classes/ini directives get registered. RINIT (Request Init) fires at the start of every request and is where request-scoped globals get reset.
[multiple-choice] After running make install, an extension still isn't showing up in php -m. What's the most likely missing step? A. The .c file needs to be renamed B. It hasn't been added via extension= in php.ini (or a conf.d ini file), and/or the process hasn't been restarted C. PECL must be reinstalled D. PHP itself needs to be recompiled from source
B. It hasn't been added via extension= in php.ini (or a conf.d ini file), and/or the process hasn't been restarted — make install only places the compiled .so on disk in extension_dir. PHP only activates extensions explicitly listed via an extension= directive, and that change only takes effect after the PHP process (php-fpm, CLI, etc.) is restarted.
[multiple-choice] Which scenario is the strongest case for writing a native extension instead of using FFI? A. Calling two functions from an already-compiled internal C library occasionally from a CLI script B. Needing to hook directly into the Zend Engine's opcode execution, the way a step-debugger does C. Parsing a small config file once at application startup D. Formatting a date string
B. Needing to hook directly into the Zend Engine's opcode execution, the way a step-debugger does — Deep engine-level hooks (intercepting opcode execution/compilation) are only possible from inside a real extension linked against the Zend Engine API -- this is exactly the kind of capability FFI, which only calls already-compiled external C functions, cannot provide.
A teammate proposes writing a full C extension to call three functions from an internal company C library that's already compiled and available as a .so on every production server. Using this lesson's FFI-vs-extension guidance, what would you ask before agreeing that a full extension is the right approach?
Ask whether any of the genuine reasons to write a full extension actually apply: does this need engine-level hooks (opcode/compilation integration), is call-site marshaling overhead actually going to be a measured bottleneck for these three specific calls, and can ffi.enable reliably be turned on for the CLI/preload contexts these calls will run in. If none of those apply -- which is the common case for 'call three functions from an already-compiled internal library' -- FFI::cdef() or FFI::load() against the library's real header is very likely the better choice: no build pipeline, no phpize/configure/make/restart cycle for every change, and far less code to maintain.
Which of the 'write a full extension' conditions from this lesson would need to actually be true here, given that the library is already compiled and only three functions are needed?