An FTDI board turns any laptop into a hardware bench. One USB cable gives you a UART, or an MPSSE engine that clocks I2C and SPI and drives GPIO pins: an FT232H, FT2232H or FT232R on a desk is a bus master for every sensor, display and radio that speaks those protocols, with no Raspberry Pi in between. On Linux the kernel's ftdi_sio driver makes the UART side a /dev/ttyUSB* node, and that's where PHP's reach ends: a serial port it cannot configure, and no MPSSE at all, because MPSSE isn't a serial protocol. It's a vendor command set spoken over raw USB bulk transfers.
libftdi1 is the C library that speaks it. It opens the chip through libusb, switches it into bit-bang or MPSSE mode, moves bytes synchronously or as asynchronous transfers that libusb completes in the background, and reads and writes the EEPROM that holds the chip's USB identity. Every FTDI tool on Linux and macOS that isn't FTDI's own proprietary driver is built on it, and its documentation and examples are the reference for how these chips are driven.
The only problem? PHP has no binding to it. The route until now was FFI: parse ftdi.h, declare the structs by hand, keep the enum values in step with the installed library, and run with ffi.enable on. This extension binds libftdi one to one under libftdi's own function names, in C against the Zend API, so the C documentation applies unchanged. It adds PHP objects that own the C pointers, so a PHP program cannot free a context twice or touch memory libusb has already released, and it carries every enum member and numeric define from ftdi.h with the values of the library it was built against. No event loop, no MPSSE helpers, no opinions: those live one layer up.
ext-ftdi libftdi1 calls, 1:1 ← this package
→ microscrap/mpsse MPSSE I2C, SPI and GPIO in PHP
→ microscrap/scrapyard-usb the `usb` driver
→ scrapyard-io/framework protocol managers, transports, circuits
Most ways you will interface with ext-ftdi are in two parts -
Make a context with ftdi_new(), find the device you want with ftdi_usb_find_all(), and open it. Here an FT232H is opened by its USB IDs, reset, and switched into MPSSE mode; every call returns libftdi's own code, and ftdi_get_error_string() explains a negative one.
use Ftdi\FtdiProductId;
use Ftdi\FtdiVendorId;
$ftdi = ftdi_new() ?? throw new RuntimeException('ftdi_new() failed');
// What is on the bus? Each device comes back as an FTDIDevice the context can open or describe.
$found = ftdi_usb_find_all($ftdi, FtdiVendorId::FTDI->value, FtdiProductId::FT232H->value);
foreach ($found['devices'] as $device) {
$strings = ftdi_usb_get_strings($ftdi, $device);
printf("%s, serial %s\n", $strings['description'], $strings['serial']);
}
// Open the first FT232H and put its engine into MPSSE mode.
if (ftdi_usb_open($ftdi, FtdiVendorId::FTDI->value, FtdiProductId::FT232H->value) < 0) {
throw new RuntimeException(ftdi_get_error_string($ftdi));
}
ftdi_usb_reset($ftdi);
ftdi_set_bitmode($ftdi, 0x00, BITMODE_RESET);
ftdi_set_bitmode($ftdi, 0x00, BITMODE_MPSSE);
ftdi_tcio_flush($ftdi);Talk to it: write MPSSE opcodes with ftdi_write_data() and read the answer with ftdi_read_data(), exactly as C would. Here the GET_BITS_LOW opcode reads the eight ADBUS pins. Close and free the context yourself when you're done.
// SEND_IMMEDIATE makes the chip answer now instead of when its buffer fills.
ftdi_write_data($ftdi, chr(GET_BITS_LOW) . chr(SEND_IMMEDIATE), 2);
$reply = '';
while (strlen($reply) < 1) {
$chunk = ftdi_read_data($ftdi, 1); // "" until the answer arrives
if ($chunk === false) {
throw new RuntimeException(ftdi_get_error_string($ftdi));
}
$reply .= $chunk;
}
printf("ADBUS 0b%08b\n", ord($reply));
ftdi_usb_close($ftdi);
ftdi_free($ftdi);The submit functions return at once with an FTDITransferControl. libusb completes the transfer while its events are handled, either by calling ftdi_handle_events_timeout() as here, or by adding the descriptors from ftdi_get_pollfds() to your own event loop and calling ftdi_handle_events_timeout($ftdi, 0) when any of them fires:
$tx = ftdi_write_data_submit($ftdi, chr(GET_BITS_LOW) . chr(SEND_IMMEDIATE), 2)
?? throw new RuntimeException(ftdi_get_error_string($ftdi));
$rx = ftdi_read_data_submit($ftdi, 1)
?? throw new RuntimeException(ftdi_get_error_string($ftdi));
while (! ftdi_transfer_completed($rx)) {
ftdi_handle_events_timeout($ftdi, 10_000); // microseconds
}
$written = ftdi_transfer_data_done($tx); // 2
$pins = ftdi_transfer_read_done($rx); // the byte read
ftdi_usb_close($ftdi);
ftdi_free($ftdi);| OS | Linux or macOS. Windows is excluded in composer.json. |
| PHP | 8.4 or newer, NTS or ZTS. |
| Libraries | libftdi1 1.5 or newer and libusb-1.0 1.0.16 or newer, with their headers, found through pkg-config. 1.4 lacks functions and constants this extension binds; configure stops below 1.5. |
| Device access | Read-write access to the USB device. On Linux, run as root or add a udev rule such as SUBSYSTEM=="usb", ATTR{idVendor}=="0403", MODE="0660", GROUP="plugdev" and join that group. libftdi detaches the kernel's ftdi_sio serial driver from the interface it opens. |
To build it you need a C compiler, pkg-config, the two libraries' headers and the development files of the PHP you intend to load it into:
# Debian, Ubuntu, Raspberry Pi OS
sudo apt install build-essential pkg-config libftdi1-dev libusb-1.0-0-dev php8.4-dev
# macOS: Xcode Command Line Tools, and Homebrew's PHP ships phpize and php-config
xcode-select --install
brew install php@8.4 libftdi libusb pkgconfvenusian build reads the same packages from extra.venusian.system in composer.json.
PIE is the PHP Foundation's installer for extensions. It downloads the tagged source from Packagist, builds it against the PHP on your PATH, installs the module and enables it. The libraries above must be installed first, since configure looks them up. sudo where your PHP's extension directory is root-owned, as on Debian; not on a Homebrew Mac:
pie install php-io-extensions/ftdiThe repository ships two installers, one per platform. Each one checks for libftdi1 and libusb, then for each PHP it targets builds in a temporary copy of the sources, installs ftdi.so into that PHP's extension_dir, writes 30-ftdi.ini into its conf.d scan directory, checks the module loads, and leaves the checkout clean. The macOS one also ad-hoc signs the module so the system loads it on first use.
git clone https://github.com/php-io-extensions/ftdi.git
cd ftdi
./install-debian-trixie.sh # Debian Trixie, Ubuntu 24.04+, Raspberry Pi OS: the php on PATH
./install-macos.sh # Homebrew php@8.4 and php@8.4-zts
./install-macos.sh /path/to/bin/php # either script takes specific PHP binariesgit clone https://github.com/php-io-extensions/ftdi.git
cd ftdi
phpize
./configure --enable-ftdi
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
sudo make installThen enable it: add extension=ftdi.so to your php.ini, or drop that line into a file in the scan directory that php --ini reports.
$ php --ri ftdi
ftdi
ftdi support => enabled
Version => 0.10.x
libftdi1 => 1.5The names are libftdi's. Four are spelt as microscrap/ftdi exported them: ftdi_tci_flush, ftdi_tco_flush, ftdi_tcio_flush and ftdi_read_chip_id. Every function is global; the objects and the two USB ID enums live in the Ftdi namespace. Return codes are libftdi's, never thrown; ftdi_get_error_string() explains a negative one.
| Class | Wraps | Lifetime |
|---|---|---|
Ftdi\FTDIContext |
struct ftdi_context * |
Created by ftdi_new(); released by ftdi_free() or garbage collection. Its read-only properties read the struct live: chipType, usbReadTimeout, usbWriteTimeout, interfaceIndex, baudrate, bitbangEnabled, bitbangMode, channel, inEndpoint, outEndpoint, readBufferChunkSize, writeBufferChunkSize, maxPacketSize, moduleDetachMode, errorStr. toArray() returns them all. |
Ftdi\FTDIDevice |
struct libusb_device *, referenced |
Listed by ftdi_usb_find_all(); usable with the context that found it until that context is deinitialised. |
Ftdi\FTDITransferControl |
struct ftdi_transfer_control * and its buffer |
Returned by ftdi_write_data_submit() and ftdi_read_data_submit(); owns the buffer until the transfer is collected or cancelled. Live read-only completed, size, offset, and toArray(). |
Ftdi\FTDIVersionInfo |
struct ftdi_version_info |
Readonly snapshot: major, minor, micro, versionStr, snapshotStr. |
Ftdi\FTDIEeprom |
the context's EEPROM image | Readonly snapshot from ftdi_get_eeprom(): every enum ftdi_eeprom_value as a camelCase int, vendorId through userDataAddr, 0 where the chip has no such value, plus manufacturer, product and serial. Meaningful after ftdi_read_eeprom() and ftdi_eeprom_decode(). |
None of them can be created with new, cloned or serialised.
A context keeps track of the transfers and devices made from it, so nothing ever reaches freed libusb memory:
ftdi_usb_close(),ftdi_deinit(),ftdi_free(),ftdi_init()over an initialised context, and the context's release all cancel its pending transfers first.ftdi_deinit(),ftdi_free()and the context's release also drop its device references before libusb shuts down.- A transfer object released while its transfer is pending cancels the transfer.
| Group | Functions |
|---|---|
| Context | ftdi_new(): ?FTDIContext, ftdi_init, ftdi_deinit, ftdi_free, ftdi_set_interface, ftdi_get_library_version(): FTDIVersionInfo, ftdi_get_error_string |
| Devices | ftdi_usb_find_all(ctx, vendor, product): array{count, devices: list<FTDIDevice>}, ftdi_usb_get_strings / ftdi_usb_get_strings2(ctx, device): array{result, manufacturer, description, serial}, ftdi_usb_open_dev, ftdi_usb_open, ftdi_usb_open_desc, ftdi_usb_open_desc_index, ftdi_usb_open_bus_addr, ftdi_usb_open_string, ftdi_usb_close, ftdi_usb_reset |
| Flushes | ftdi_tci_flush, ftdi_tco_flush, ftdi_tcio_flush, and the ftdi_usb_purge_* calls that ftdi.h deprecates |
| Line | ftdi_convert_baudrate_ut_export(baud, ctx): array{result, value, index}, ftdi_set_baudrate, ftdi_set_line_property, ftdi_set_line_property2, ftdi_set_bitmode, ftdi_disable_bitbang, ftdi_read_pins, ftdi_set_latency_timer, ftdi_get_latency_timer, ftdi_set_timeouts, ftdi_poll_modem_status, ftdi_setflowctrl, ftdi_setflowctrl_xonxoff, ftdi_setdtr, ftdi_setrts, ftdi_setdtr_rts, ftdi_set_event_char, ftdi_set_error_char |
| Synchronous data | ftdi_write_data(ctx, data, size): int, ftdi_read_data(ctx, size): string|false, ftdi_write_data_set_chunksize, ftdi_write_data_get_chunksize, ftdi_read_data_set_chunksize, ftdi_read_data_get_chunksize |
| Asynchronous data | ftdi_write_data_submit(ctx, data, size) and ftdi_read_data_submit(ctx, size): ?FTDITransferControl, ftdi_transfer_data_done(tc): int, ftdi_transfer_read_done(tc): string|false, ftdi_transfer_data_cancel, ftdi_transfer_completed |
| libusb event pump | ftdi_get_pollfds(ctx): list<array{fd, events}>, ftdi_pollfds_handle_timeouts, ftdi_get_next_timeout(ctx): array{result, usec}, ftdi_handle_events_timeout(ctx, timeout_us) |
| EEPROM | ftdi_get_eeprom(ctx): FTDIEeprom, ftdi_eeprom_initdefaults, ftdi_eeprom_set_strings, ftdi_eeprom_get_strings, ftdi_eeprom_build, ftdi_eeprom_decode, ftdi_get_eeprom_value, ftdi_set_eeprom_value, ftdi_get_eeprom_buf(ctx, size): string|false, ftdi_set_eeprom_buf, ftdi_set_eeprom_user_data, ftdi_set_ft232h_cbus(ctx): string, ftdi_read_eeprom_location, ftdi_read_eeprom, ftdi_read_chip_id(ctx, &chip_id), ftdi_write_eeprom_location, ftdi_write_eeprom, ftdi_erase_eeprom |
- Return codes are libftdi's.
ftdi_get_error_string()explains a negative one. - Out parameters: a call that reads one value through a C out parameter returns that value, or the negative code on failure. These are
ftdi_read_pins,ftdi_get_latency_timer,ftdi_poll_modem_status, the chunk size getters,ftdi_get_eeprom_valueandftdi_read_eeprom_location. - Arguments are checked against the C type: bytes 0–255, vendor and product IDs 0–0xFFFF, sizes within the data given, enum arguments within the enum. Anything outside throws
ValueErrorinstead of being truncated. - Failure without a value is null where an object is returned (
ftdi_new()and the two submit functions) and false where bytes are returned (ftdi_read_data,ftdi_transfer_read_done,ftdi_get_eeprom_buf). - Misuse throws
Error: calling into a context thatftdi_free()released (a secondftdi_free()does nothing), calling into a deinitialised context beforeftdi_init(), or passing a device from another context or from before a deinit. - Transfers:
- With no device open, the submit functions return null and set the error string to "USB device unavailable", as libftdi's synchronous calls do. libftdi 1.5's submit functions return NULL there without setting any error string.
ftdi_transfer_data_done()andftdi_transfer_read_done()wait for the transfer, free it, and return the bytes moved. Called again on a transfer that was already collected or cancelled, they return -1 and false.- Cancelling a transfer that was already collected or cancelled does nothing.
- Re-initialising:
ftdi_init()over an initialised context deinitialises it first. Plain libftdi would leak the old libusb context. - EEPROM strings: libftdi 1.5's
ftdi_eeprom_get_strings()copies each string without a NULL check, and a fresh context,ftdi_eeprom_initdefaults()orftdi_eeprom_decode()can leave any of the three NULL. The context records which strings libftdi holds and asks only for those; the others come back as "". - Not bound:
ftdi_list_freeandftdi_list_free2, becauseftdi_usb_find_all()frees the list itself and eachFTDIDeviceholds its own libusb reference; andftdi_set_usbdev, because PHP has no way to obtain a libusb device handle.
Every member of every enum in ftdi.h, under libftdi's own unprefixed names:
| Enum | Constants |
|---|---|
| chip and line | TYPE_*, NONE, ODD, EVEN, MARK, SPACE, STOP_BIT_*, BITS_*, BREAK_* |
| modes | BITMODE_*, INTERFACE_*, AUTO_DETACH_SIO_MODULE, DONT_DETACH_SIO_MODULE, AUTO_DETACH_REATACH_SIO_MODULE |
| EEPROM | the enum ftdi_eeprom_value names (VENDOR_ID … USER_DATA_ADDR), CBUS_*, CBUSH_*, CBUSX_* |
And every numeric #define in ftdi.h:
| Group | Constants |
|---|---|
| MPSSE opcodes and flags | MPSSE_*, SET_BITS_LOW, SET_BITS_HIGH, GET_BITS_LOW, GET_BITS_HIGH, TCK_DIVISOR, SEND_IMMEDIATE, WAIT_ON_HIGH, WAIT_ON_LOW, … |
| control requests and flags | SIO_*, FTDI_DEVICE_OUT_REQTYPE, FTDI_DEVICE_IN_REQTYPE |
| EEPROM bits | INVERT_*, CHANNEL_IS_*, DRIVE_*, … |
Every value comes from ftdi.h at build time. SIO_RESET_PURGE_RX and SIO_RESET_PURGE_TX are left out: ftdi.h deprecates them with a compile warning in favour of SIO_TCIFLUSH and SIO_TCOFLUSH.
Enums: Ftdi\FtdiVendorId::FTDI (0x0403) and Ftdi\FtdiProductId::{FT232R, FT2232H, FT4232H, FT232H, FT230X, FT4232HP, FT4232HA}.
- ext-epoll: Linux. The descriptors
ftdi_get_pollfds()returns register withepoll_ctl(), so libusb's transfers complete inside your own loop: when one fires, callftdi_handle_events_timeout($ftdi, 0). Whenftdi_pollfds_handle_timeouts()returns0, libusb's own descriptors don't cover its timeouts, andftdi_get_next_timeout()is theepoll_wait()timeout to honour. - ext-kqueue: macOS. The same with
EV_SET()andkevent(). - ext-posi: the serial side. When a chip stays on the kernel's
ftdi_siodriver as/dev/ttyUSB0, posi'sposix_open()and termios calls drive it as a plain UART, with no libftdi and no detach.
This codebase was built with LLM assistance under human direction. Prompts, reviews, and architectural decisions were owned by a professional software engineer — the model accelerates drafting and exploration; it does not replace judgment on APIs, dependency direction, or what ships.
This repository carries an Open Knowledge Format bundle at .okf/: Markdown with YAML frontmatter, readable by you and loadable by a coding agent. It records what the README doesn't: how the module is laid out and linked, every place libftdi 1.5 would crash, leak or mislead from PHP and the guard that prevents it, how the objects own their pointers and tear down in order, the async pump, the EEPROM image, how the constant list is kept in step with ftdi.h, the 0.9 to 0.10 call map, and a log of every change that altered behaviour.
Start at .okf/index.md. AGENTS.md is the short rule sheet an agent reads before touching the C, the stub, or packaging; each rule points into the bundle for the reason behind it.
Point your agent at the repo and it has the same context the maintainer has. Point yourself at it when the README's answer is "it does X" and you want "because".
- libftdi1 and its API reference: the library this extension binds. Its documentation is the manual; this README only adds what PHP changes.
- ftdi.h: the header every constant here comes from, and where each MPSSE opcode and EEPROM bit is defined.
- Upgrading from 0.9: the Zephir
Ftdi\FTDI::ftdi*statics andmicroscrap/ftdihelpers mapped call by call onto 0.10. - PIE: the PHP Foundation's extension installer.
gen_stub.php: the php-src stub format.ftdi.stub.phpis the API declaration;ftdi_arginfo.his generated from it.- Open Knowledge Format: the spec the
.okf/bundle follows.
The suite is Pest v4 and needs no device: constants, enums, the version snapshot, construction refusals, live properties, the codes a missing device produces, ValueError ranges, the EEPROM snapshot shape, released and deinitialised contexts, and an idle libusb pump. It runs on Linux and macOS. Build the module in a copy of the four sources, then point php at it:
git clone https://github.com/php-io-extensions/ftdi.git
cd ftdi
mkdir ../ftdi-build && cp config.m4 ftdi.c php_ftdi.h ftdi_arginfo.h ../ftdi-build/
(cd ../ftdi-build && phpize && ./configure --enable-ftdi && make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)")
composer install
php -n -d extension="$PWD/../ftdi-build/modules/ftdi.so" vendor/bin/pestTests: 12 passed (85 assertions)
Duration: 0.08s-n skips your php.ini, so the suite tests the build and not an installed ftdi.so; without it, a PHP that already loads ftdi (the 0.9 Zephir extension carried the same module name) warns Module "ftdi" is already loaded and keeps the installed one. A module only loads in the PHP whose phpize built it: for Homebrew's ZTS build, run phpize and pass --with-php-config from /opt/homebrew/opt/php@8.4-zts/bin. Checks against a real FT232H are scratch scripts run by hand and never part of the suite; the suite never writes EEPROM.
Open an issue first for anything beyond a typo. Pull requests change ftdi.stub.php and ftdi.c together, regenerate ftdi_arginfo.h with gen_stub, and add a Pest test for the behaviour. A constant new in a later libftdi raises the config.m4 floor and both installers' check with it. CI must be green on every cell of the matrix. Read AGENTS.md for the rules the code follows.
ext-ftdi gives PHP code raw access to USB devices it can open, including writing their EEPROM. See SECURITY.md for what that means and how to report a vulnerability.
MIT. See LICENSE.