From b749f52ae56602fe0638585a1265775dd79a007c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 12 Sep 2025 11:55:24 +0200 Subject: [PATCH 1/9] chore: simplify string using backticks # Conflicts: # internal/extgen/classparser.go # internal/extgen/gofile_test.go --- caddy/app.go | 2 +- internal/extgen/cfile_test.go | 6 +++--- internal/extgen/constparser.go | 2 +- internal/extgen/constparser_test.go | 12 ++++++------ internal/extgen/stub_test.go | 2 +- internal/extgen/utils.go | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index 01831c53..9170ff03 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -42,7 +42,7 @@ type FrankenPHPApp struct { logger *slog.Logger } -var iniError = errors.New("'php_ini' must be in the format: php_ini \"\" \"\"") +var iniError = errors.New(`"php_ini" must be in the format: php_ini "" ""`) // CaddyModule returns the Caddy module information. func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo { diff --git a/internal/extgen/cfile_test.go b/internal/extgen/cfile_test.go index f9d8c749..0a8d250f 100644 --- a/internal/extgen/cfile_test.go +++ b/internal/extgen/cfile_test.go @@ -313,7 +313,7 @@ func TestCFileSpecialCharacters(t *testing.T) { content, err := cGen.buildContent() require.NoError(t, err) - expectedInclude := "#include \"" + tt.expected + ".h\"" + expectedInclude := `#include "` + tt.expected + `.h"` assert.Contains(t, content, expectedInclude, "Content should contain include: %s", expectedInclude) }) } @@ -424,8 +424,8 @@ func TestCFileConstants(t *testing.T) { }, }, contains: []string{ - "REGISTER_LONG_CONSTANT(\"GLOBAL_INT\", 42, CONST_CS | CONST_PERSISTENT);", - "REGISTER_STRING_CONSTANT(\"GLOBAL_STRING\", \"test\", CONST_CS | CONST_PERSISTENT);", + `REGISTER_LONG_CONSTANT("GLOBAL_INT", 42, CONST_CS | CONST_PERSISTENT);`, + `REGISTER_STRING_CONSTANT("GLOBAL_STRING", "test", CONST_CS | CONST_PERSISTENT);`, }, }, } diff --git a/internal/extgen/constparser.go b/internal/extgen/constparser.go index a61f23de..2f304895 100644 --- a/internal/extgen/constparser.go +++ b/internal/extgen/constparser.go @@ -99,7 +99,7 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e func determineConstantType(value string) phpType { value = strings.TrimSpace(value) - if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || + if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) || (strings.HasPrefix(value, "`") && strings.HasSuffix(value, "`")) { return phpString } diff --git a/internal/extgen/constparser_test.go b/internal/extgen/constparser_test.go index 549bc912..29f0e38f 100644 --- a/internal/extgen/constparser_test.go +++ b/internal/extgen/constparser_test.go @@ -250,7 +250,7 @@ func TestConstantParserTypeDetection(t *testing.T) { value string expectedType phpType }{ - {"string with double quotes", "\"hello world\"", phpString}, + {"string with double quotes", `"hello world"`, phpString}, {"string with backticks", "`hello world`", phpString}, {"boolean true", "true", phpBool}, {"boolean false", "false", phpBool}, @@ -443,13 +443,13 @@ func TestConstantParserDeclRegex(t *testing.T) { name string value string }{ - {"const MyConst = \"value\"", true, "MyConst", "\"value\""}, + {`const MyConst = "value"`, true, "MyConst", `"value"`}, {"const IntConst = 42", true, "IntConst", "42"}, {"const BoolConst = true", true, "BoolConst", "true"}, {"const IotaConst = iota", true, "IotaConst", "iota"}, {"const ComplexValue = someFunction()", true, "ComplexValue", "someFunction()"}, - {"const SpacedName = \"with spaces\"", true, "SpacedName", "\"with spaces\""}, - {"var notAConst = \"value\"", false, "", ""}, + {`const SpacedName = "with spaces"`, true, "SpacedName", `"with spaces"`}, + {`var notAConst = "value"`, false, "", ""}, {"const", false, "", ""}, {"const =", false, "", ""}, } @@ -518,10 +518,10 @@ func TestPHPConstantCValue(t *testing.T) { name: "string constant", constant: phpConstant{ Name: "StringConst", - Value: "\"hello\"", + Value: `"hello"`, PhpType: phpString, }, - expected: "\"hello\"", // strings should remain unchanged + expected: `"hello"`, // strings should remain unchanged }, { name: "boolean constant", diff --git a/internal/extgen/stub_test.go b/internal/extgen/stub_test.go index 418e9587..b9d689ff 100644 --- a/internal/extgen/stub_test.go +++ b/internal/extgen/stub_test.go @@ -134,7 +134,7 @@ func TestStubGenerator_BuildContent(t *testing.T) { contains: []string{ " Date: Mon, 13 Oct 2025 16:22:11 +0200 Subject: [PATCH 2/9] docs: improve Mercure documentation and various other parts --- .gitleaksignore | 1 + caddy/extinit.go | 2 +- docs/config.md | 107 ++++++++++++++++++++++++++------------ docs/docker.md | 9 +++- docs/extensions.md | 8 +-- docs/known-issues.md | 12 +++-- docs/laravel.md | 30 +++++++++++ docs/mercure.md | 121 +++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 243 insertions(+), 47 deletions(-) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..8556981e --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1 @@ +/github/workspace/docs/mercure.md:jwt:65 diff --git a/caddy/extinit.go b/caddy/extinit.go index f0327f57..c990b99f 100644 --- a/caddy/extinit.go +++ b/caddy/extinit.go @@ -17,7 +17,7 @@ func init() { caddycmd.RegisterCommand(caddycmd.Command{ Name: "extension-init", Usage: "go_extension.go [--verbose]", - Short: "(Experimental) Initializes a PHP extension from a Go file", + Short: "Initializes a PHP extension from a Go file (EXPERIMENTAL)", Long: ` Initializes a PHP extension from a Go file. This command generates the necessary C files for the extension, including the header and source files, as well as the arginfo file.`, CobraFunc: func(cmd *cobra.Command) { diff --git a/docs/config.md b/docs/config.md index 64654a18..306d7e3f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,17 +1,38 @@ # Configuration -FrankenPHP, Caddy as well as the Mercure and Vulcain modules can be configured using [the formats supported by Caddy](https://caddyserver.com/docs/getting-started#your-first-config). +FrankenPHP, Caddy as well as the [Mercure](mercure.md) and [Vulcain](https://vulcain.rocks) modules can be configured using [the formats supported by Caddy](https://caddyserver.com/docs/getting-started#your-first-config). -In [the Docker images](docker.md), the `Caddyfile` is located at `/etc/frankenphp/Caddyfile`. -The static binary will also look for the `Caddyfile` in the directory where the `frankenphp run` command is executed. +The most common format is the `Caddyfile`, which is a simple, human-readable text format. +By default, FrankenPHP will look for a `Caddyfile` in the current directory. You can specify a custom path with the `-c` or `--config` option. +A minimal `Caddyfile` to serve a PHP application is shown below: + +```caddyfile +# The hostname to respond to +localhost + +# Optionaly, the directory to serve files from, otherwise defaults to the current directory +#root public/ +php_server +``` + +A more advanced `Caddyfile` enabling more features and providing convenient environment variables is provided [in the FrankenPHP repository](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile), +and with Docker images. + PHP itself can be configured [using a `php.ini` file](https://www.php.net/manual/en/configuration.file.php). -Depending on your installation method, the PHP interpreter will look for configuration files in locations described below. +Depending on your installation method, FrankenPHP and the PHP interpreter will look for configuration files in locations described below. ## Docker +FrankenPHP: + +- `/etc/frankenphp/Caddyfile`: the main configuration file +- `/etc/frankenphp/caddy.d/*.caddy`: additional configuration files that are loaded automatically + +PHP: + - `php.ini`: `/usr/local/etc/php/php.ini` (no `php.ini` is provided by default) - additional configuration files: `/usr/local/etc/php/conf.d/*.ini` - PHP extensions: `/usr/local/lib/php/extensions/no-debug-zts-/` @@ -29,12 +50,25 @@ RUN cp $PHP_INI_DIR/php.ini-development $PHP_INI_DIR/php.ini ## RPM and Debian packages +FrankenPHP: + +- `/etc/frankenphp/Caddyfile`: the main configuration file +- `/etc/frankenphp/caddy.d/*.caddy`: additional configuration files that are loaded automatically + +PHP: + - `php.ini`: `/etc/frankenphp/php.ini` (a `php.ini` file with production presets is provided by default) - additional configuration files: `/etc/frankenphp/php.d/*.ini` - PHP extensions: `/usr/lib/frankenphp/modules/` ## Static binary +FrankenPHP: + +- In the current working directory: `Caddyfile` + +PHP: + - `php.ini`: The directory in which `frankenphp run` or `frankenphp php-server` is executed, then `/etc/frankenphp/php.ini` - additional configuration files: `/etc/frankenphp/php.d/*.ini` - PHP extensions: cannot be loaded, bundle them in the binary itself @@ -229,34 +263,6 @@ and otherwise forward the request to the worker matching the path pattern. } ``` -### Full Duplex (HTTP/1) - -When using HTTP/1.x, it may be desirable to enable full-duplex mode to allow writing a response before the entire body -has been read. (for example: WebSocket, Server-Sent Events, etc.) - -This is an opt-in configuration that needs to be added to the global options in the `Caddyfile`: - -```caddyfile -{ - servers { - enable_full_duplex - } -} -``` - -> [!CAUTION] -> -> Enabling this option may cause old HTTP/1.x clients that don't support full-duplex to deadlock. -> This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config: - -```sh -CADDY_GLOBAL_OPTIONS="servers { - enable_full_duplex -}" -``` - -You can find more information about this setting in the [Caddy documentation](https://caddyserver.com/docs/caddyfile/options#enable-full-duplex). - ## Environment Variables The following environment variables can be used to inject Caddy directives in the `Caddyfile` without modifying it: @@ -293,6 +299,43 @@ You can also change the PHP configuration using the `php_ini` directive in the ` } ``` +### Disabling HTTPS + +By default, FrankenPHP will automatically enable HTTPS using for all the hostnames, including `localhost`. +If you want to disable HTTPS (for example in a development environment), you can set the `SERVER_NAME` environment variable to `http://` or `:80`: + +Alternatively, you can use all other methods described in the [Caddy documentation](https://caddyserver.com/docs/automatic-https#activation). + +If you want to use HTTPS with the `127.0.0.1` IP address instead of the `localhost` hostname, please read the [known issues](known-issues.md#using-https127001-with-docker) section. + +### Full Duplex (HTTP/1) + +When using HTTP/1.x, it may be desirable to enable full-duplex mode to allow writing a response before the entire body +has been read. (for example: [Mercure](mercure.md), WebSocket, Server-Sent Events, etc.) + +This is an opt-in configuration that needs to be added to the global options in the `Caddyfile`: + +```caddyfile +{ + servers { + enable_full_duplex + } +} +``` + +> [!CAUTION] +> +> Enabling this option may cause old HTTP/1.x clients that don't support full-duplex to deadlock. +> This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config: + +```sh +CADDY_GLOBAL_OPTIONS="servers { + enable_full_duplex +}" +``` + +You can find more information about this setting in the [Caddy documentation](https://caddyserver.com/docs/caddyfile/options#enable-full-duplex). + ## Enable the Debug Mode When using the Docker image, set the `CADDY_GLOBAL_OPTIONS` environment variable to `debug` to enable the debug mode: diff --git a/docs/docker.md b/docs/docker.md index df52b1e8..9c4fbdab 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,6 +1,8 @@ # Building Custom Docker Image -[FrankenPHP Docker images](https://hub.docker.com/r/dunglas/frankenphp) are based on [official PHP images](https://hub.docker.com/_/php/). Debian and Alpine Linux variants are provided for popular architectures. Debian variants are recommended. +[FrankenPHP Docker images](https://hub.docker.com/r/dunglas/frankenphp) are based on [official PHP images](https://hub.docker.com/_/php/). +Debian and Alpine Linux variants are provided for popular architectures. +Debian variants are recommended. Variants for PHP 8.2, 8.3 and 8.4 are provided. @@ -28,6 +30,11 @@ docker build -t my-php-app . docker run -it --rm --name my-running-app my-php-app ``` +## How to Tweak the Configuration + +For convenience, [a default `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile) containing +useful environment variables is provided in the image. + ## How to Install More PHP Extensions The [`docker-php-extension-installer`](https://github.com/mlocati/docker-php-extension-installer) script is provided in the base image. diff --git a/docs/extensions.md b/docs/extensions.md index 27865d4c..2c2253b9 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -33,7 +33,7 @@ As covered in the manual implementation section below as well, you need to [get The first step to writing a PHP extension in Go is to create a new Go module. You can use the following command for this: ```console -go mod init example.com/example +go mod init example.com/example ``` The second step is to [get the PHP sources](https://www.php.net/downloads.php) for the next steps. Once you have them, decompress them into the directory of your choice, not inside your Go module: @@ -49,7 +49,7 @@ Everything is now setup to write your native function in Go. Create a new file n ```go package example -// #include +// #include import "C" import ( "strings" @@ -126,7 +126,7 @@ package example import "C" import ( "unsafe" - + "github.com/dunglas/frankenphp" ) @@ -790,7 +790,7 @@ import "C" import ( "unsafe" "strings" - + "github.com/dunglas/frankenphp" ) diff --git a/docs/known-issues.md b/docs/known-issues.md index 947089cc..da226903 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -13,9 +13,9 @@ The following extensions are known not to be compatible with FrankenPHP: The following extensions have known bugs and unexpected behaviors when used with FrankenPHP: -| Name | Problem | -| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [ext-openssl](https://www.php.net/manual/en/book.openssl.php) | When using a static build of FrankenPHP (built with the musl libc), the OpenSSL extension may crash under heavy loads. A workaround is to use a dynamically linked build (like the one used in Docker images). This bug is [being tracked by PHP](https://github.com/php/php-src/issues/13648). | +| Name | Problem | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [ext-openssl](https://www.php.net/manual/en/book.openssl.php) | When using musl libc, the OpenSSL extension may crash under heavy loads. The problem doesn't occur when using the more popular GNU libc. This bug is [being tracked by PHP](https://github.com/php/php-src/issues/13648). | ## get_browser @@ -23,7 +23,11 @@ The [get_browser()](https://www.php.net/manual/en/function.get-browser.php) func ## Standalone Binary and Alpine-based Docker Images -The standalone binary and Alpine-based Docker images (`dunglas/frankenphp:*-alpine`) use [musl libc](https://musl.libc.org/) instead of [glibc and friends](https://www.etalabs.net/compare_libcs.html), to keep a smaller binary size. This may lead to some compatibility issues. In particular, the glob flag `GLOB_BRACE` is [not available](https://www.php.net/manual/en/function.glob.php) +The fully binary and Alpine-based Docker images (`dunglas/frankenphp:*-alpine`) use [musl libc](https://musl.libc.org/) instead of [glibc and friends](https://www.etalabs.net/compare_libcs.html), to keep a smaller binary size. +This may lead to some compatibility issues. +In particular, the glob flag `GLOB_BRACE` is [not available](https://www.php.net/manual/en/function.glob.php) + +Prefer using the GNU variant of the static binary and Debian-based Docker images if you encounter issues. ## Using `https://127.0.0.1` with Docker diff --git a/docs/laravel.md b/docs/laravel.md index 54203367..094622c4 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -76,6 +76,8 @@ The `octane:frankenphp` command can take the following options: > [!TIP] > To get structured JSON logs (useful when using log analytics solutions), explicitly the pass `--log-level` option. +See also [how to use Mercure with Octane](#mercure-support). + Learn more about [Laravel Octane in its official documentation](https://laravel.com/docs/octane). ## Laravel Apps As Standalone Binaries @@ -166,6 +168,34 @@ This is not suitable for embedded applications, as each new version will be extr Set the `LARAVEL_STORAGE_PATH` environment variable (for example, in your `.env` file) or call the `Illuminate\Foundation\Application::useStoragePath()` method to use a directory outside the temporary directory. +### Mercure Support + +[Mercure](https://mercure.rocks) is a great way to add real-time capabilities to your Laravel apps. +FrankenPHP includes [Mercure support out of the box](mercure.md). + +If you are not using [Octane](#laravel-octane), see [the Mercure documentation entry](mercure.md). + +If you are using Octane, you can use enable Mercure support by adding the following lines to your `config/octane.php` file: + +```php +// ... + +return [ + // ... + + 'mercure' => [ + 'anonymous' => true, + 'publisher_jwt' => '!ChangeThisMercureHubJWTSecretKey!', + 'subscriber_jwt' => '!ChangeThisMercureHubJWTSecretKey!', + ], +]; +``` + +You can use [all directives supported by Mercure](https://mercure.rocks/docs/hub/config#directives) in this array. + +To publish and subscribe to updates, we recommend using the [Laravel Mercure Broadcaster](https://github.com/mvanduijker/laravel-mercure-broadcaster) library. +Alternatively, see [the Mercure documentation](mercure.md) to do it in pure PHP and JavaScript. + ### Running Octane With Standalone Binaries It's even possible to package Laravel Octane apps as standalone binaries! diff --git a/docs/mercure.md b/docs/mercure.md index 34ce3b39..ceebad20 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -3,13 +3,124 @@ FrankenPHP comes with a built-in [Mercure](https://mercure.rocks) hub! Mercure allows you to push real-time events to all the connected devices: they will receive a JavaScript event instantly. -No JS library or SDK is required! +It's a convenient alternative to WebSockets that is simple to use and is natively supported by all modern web browsers! ![Mercure](mercure-hub.png) -To enable the Mercure hub, update the `Caddyfile` as described [on Mercure's site](https://mercure.rocks/docs/hub/config). +## Enabling Mercure -The path of the Mercure hub is `/.well-known/mercure`. -When running FrankenPHP inside Docker, the full send URL would look like `http://php/.well-known/mercure` (with `php` being the container's name running FrankenPHP). +Mercure support is disabled by default. +Here is a minimal example of a `Caddyfile` enabling both FrankenPHP and the Mercure hub: -To push Mercure updates from your code, we recommend the [Symfony Mercure Component](https://symfony.com/components/Mercure) (you don't need the Symfony full-stack framework to use it). +```caddyfile +# The hostname to respond to +localhost + +mercure { + # The secret key used to sign the JWT tokens for publishers + publisher_jwt !ChangeThisMercureHubJWTSecretKey! + # Allows anonymous subscribers (without JWT) + anonymous +} + +root public/ +php_server +``` + +> [!TIP] +> +> The [sample `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile) +> provided by [the Docker images](docker.md) already includes a commented Mercure configuration +> with convenient environment variables to configure it. +> +> Uncomment the Mercure section in `/etc/frankenphp/Caddyfile` to enable it. + +## Subscribing to Updates + +By default, the Mercure hub is available on the `/.well-known/mercure` path of your FrankenPHP server. +To subscribe to updates, use the native [`EventSource`](https://developer.mozilla.org/docs/Web/API/EventSource) JavaScript class: + +```html + + +Mercure Example + +``` + +## Publishing Updates + +### Using `file_put_contents()` + +To dispatch an update to connected subscribers, send an authenticated POST request to the Mercure hub with the `topic` and `data` parameters: + +```php + [ + 'method' => 'POST', + 'header' => "Content-type: application/x-www-form-urlencoded\r\nAuthorization: Bearer " . JWT, + 'content' => http_build_query([ + 'topic' => 'my-topic', + 'data' => json_encode(['key' => 'value']), + ]), +]])); + +// Write to FrankenPHP's logs +error_log("update $updateID published", 4); +``` + +The key passed as parameter of the `mercure.publisher_jwt` option in the `Caddyfile` must used to sign the JWT token used in the `Authorization` header. + +The JWT must include a `mercure` claim with a `publish` permission for the topics you want to publish to. +See [the Mercure documentation](https://mercure.rocks/spec#publishers) about authorization. + +To generate your own tokens, you can use [this jwt.io link](https://www.jwt.io/#token=eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.PXwpfIGng6KObfZlcOXvcnWCJOWTFLtswGI5DZuWSK4), +but for production apps, it's recommended to use short-lived tokens generated aerodynamically using with a trusted [JWT library](https://www.jwt.io/libraries?programming_language=php). + +### Using Symfony Mercure + +Alternatively, you can use the [Symfony Mercure Component](https://symfony.com/components/Mercure), a standalone PHP library. + +This library handled the JWT generation, update publishing as well as cookie-based authorization for subscribers. + +First, install the library using Composer: + +```console +composer require symfony/mercure lcobucci/jwt +``` + +Then, you can use it like this: + +```php +publish(new \Symfony\Component\Mercure\Update('my-topic', json_encode(['key' => 'value']))); + +// Write to FrankenPHP's logs +error_log("update $updateID published", 4); +``` + +Mercure is also natively supported by: + +- [Laravel](laravel.md#mercure-support) +- [Symfony](https://symfony.com/doc/current/mercure.html) +- [API Platform](https://api-platform.com/docs/core/mercure/) From 1fbd61959731d6848e3a2a9029c61b7f2116e439 Mon Sep 17 00:00:00 2001 From: "Laury S." Date: Tue, 14 Oct 2025 17:50:51 +0200 Subject: [PATCH 3/9] fix: remove BOM on config fr doc file (#1924) --- docs/fr/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fr/config.md b/docs/fr/config.md index 284f1106..7c52cea7 100644 --- a/docs/fr/config.md +++ b/docs/fr/config.md @@ -1,4 +1,4 @@ -# Configuration +# Configuration FrankenPHP, Caddy ainsi que les modules Mercure et Vulcain peuvent être configurés en utilisant [les formats pris en charge par Caddy](https://caddyserver.com/docs/getting-started#your-first-config). From f8ea48c3b16e9b71117d95d2488b8cd6f5e3266b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 14 Oct 2025 14:31:34 +0200 Subject: [PATCH 4/9] chore(caddy): better error handling --- caddy/app.go | 19 ++++++++++++------- caddy/caddy.go | 11 ----------- caddy/module.go | 12 +++++++++--- caddy/workerconfig.go | 2 +- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index 9170ff03..ad648efd 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -194,7 +194,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { v, err := time.ParseDuration(d.Val()) if err != nil { - return errors.New("max_wait_time must be a valid duration (example: 10s)") + return d.Err("max_wait_time must be a valid duration (example: 10s)") } f.MaxWaitTime = v @@ -202,14 +202,14 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { parseIniLine := func(d *caddyfile.Dispenser) error { key := d.Val() if !d.NextArg() { - return iniError + return d.WrapErr(iniError) } if f.PhpIni == nil { f.PhpIni = make(map[string]string) } f.PhpIni[key] = d.Val() if d.NextArg() { - return iniError + return d.WrapErr(iniError) } return nil @@ -226,7 +226,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { if !isBlock { if !d.NextArg() { - return iniError + return d.WrapErr(iniError) } err := parseIniLine(d) if err != nil { @@ -243,12 +243,12 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) } if strings.HasPrefix(wc.Name, "m#") { - return fmt.Errorf(`global worker names must not start with "m#": %q`, wc.Name) + return d.Errf(`global worker names must not start with "m#": %q`, wc.Name) } // check for duplicate workers for _, existingWorker := range f.Workers { if existingWorker.FileName == wc.FileName { - return fmt.Errorf("global workers must not have duplicate filenames: %q", wc.FileName) + return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName) } } @@ -261,7 +261,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads { - return errors.New(`"max_threads"" must be greater than or equal to "num_threads"`) + return d.Err(`"max_threads"" must be greater than or equal to "num_threads"`) } return nil @@ -279,3 +279,8 @@ func parseGlobalOption(d *caddyfile.Dispenser, _ any) (any, error) { Value: caddyconfig.JSON(app, nil), }, nil } + +var ( + _ caddy.App = (*FrankenPHPApp)(nil) + _ caddy.Provisioner = (*FrankenPHPApp)(nil) +) diff --git a/caddy/caddy.go b/caddy/caddy.go index ce4a78c7..086099c2 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -7,9 +7,7 @@ import ( "fmt" "github.com/caddyserver/caddy/v2" - "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" - "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) const ( @@ -35,12 +33,3 @@ func init() { func wrongSubDirectiveError(module string, allowedDriectives string, wrongValue string) error { return fmt.Errorf("unknown '%s' subdirective: '%s' (allowed directives are: %s)", module, wrongValue, allowedDriectives) } - -// Interface guards -var ( - _ caddy.App = (*FrankenPHPApp)(nil) - _ caddy.Provisioner = (*FrankenPHPApp)(nil) - _ caddy.Provisioner = (*FrankenPHPModule)(nil) - _ caddyhttp.MiddlewareHandler = (*FrankenPHPModule)(nil) - _ caddyfile.Unmarshaler = (*FrankenPHPModule)(nil) -) diff --git a/caddy/module.go b/caddy/module.go index eba65bc4..baa9cb05 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -71,9 +71,8 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { } for i, wc := range f.Workers { - // make the file path absolute from the public directory - // this can only be done if the root is definied inside php_server + // this can only be done if the root is defined inside php_server if !filepath.IsAbs(wc.FileName) && f.Root != "" { wc.FileName = filepath.Join(f.Root, wc.FileName) } @@ -602,7 +601,7 @@ func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f F // forward matching routes to the PHP handler routes = append(routes, caddyhttp.Route{ MatcherSetsRaw: []caddy.ModuleMap{ - caddy.ModuleMap{"path": h.JSON(allWorkerMatches)}, + {"path": h.JSON(allWorkerMatches)}, }, HandlersRaw: []json.RawMessage{ caddyconfig.JSONModuleObject(f, "handler", "php", nil), @@ -611,3 +610,10 @@ func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f F return routes } + +// Interface guards +var ( + _ caddy.Provisioner = (*FrankenPHPModule)(nil) + _ caddyhttp.MiddlewareHandler = (*FrankenPHPModule)(nil) + _ caddyfile.Unmarshaler = (*FrankenPHPModule)(nil) +) diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index 60a2c6fd..9ccbbb30 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -59,7 +59,7 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { } if d.NextArg() { - return wc, errors.New(`FrankenPHP: too many "worker" arguments: ` + d.Val()) + return wc, d.Errf(`FrankenPHP: too many "worker" arguments: %s`, d.Val()) } for d.NextBlock(1) { From 45823c51b26ed315220025b753efb38d60b020c3 Mon Sep 17 00:00:00 2001 From: Alexander Stecher <45872305+AlliBalliBaba@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:35:35 +0200 Subject: [PATCH 5/9] fix: catches panic on invalid status code (#1920) --- frankenphp.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 222bc440..6e6f41c6 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -475,9 +475,18 @@ func go_write_headers(threadIndex C.uintptr_t, status C.int, headers *C.zend_lli current = current.next } - fc.responseWriter.WriteHeader(int(status)) + goStatus := int(status) - if status >= 100 && status < 200 { + // go panics on invalid status code + // https://github.com/golang/go/blob/9b8742f2e79438b9442afa4c0a0139d3937ea33f/src/net/http/server.go#L1162 + if goStatus < 100 || goStatus > 999 { + logger.Warn(fmt.Sprintf("Invalid response status code %v", goStatus)) + goStatus = 500 + } + + fc.responseWriter.WriteHeader(goStatus) + + if goStatus >= 100 && goStatus < 200 { // Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses h := fc.responseWriter.Header() for k := range h { From 9aee496b96b670640aadcd82ab86203109f39caf Mon Sep 17 00:00:00 2001 From: Damien Calesse <2787828+kranack@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:08:40 +0200 Subject: [PATCH 6/9] Add patchelf installation in static-gnu Dockerfile (#1899) * Add patchelf installation in static-gnu Dockerfile * reduce static-builder-gnu.Dockerfile layers --------- Co-authored-by: henderkes --- static-builder-gnu.Dockerfile | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/static-builder-gnu.Dockerfile b/static-builder-gnu.Dockerfile index 6068d238..05fd1a6f 100644 --- a/static-builder-gnu.Dockerfile +++ b/static-builder-gnu.Dockerfile @@ -62,12 +62,6 @@ RUN if [ "$(uname -m)" = "aarch64" ]; then \ echo "source scl_source enable devtoolset-10" >> /etc/bashrc && \ source /etc/bashrc -# install newer cmake to build some newer libs -RUN curl -o cmake.tar.gz -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.4/cmake-3.31.4-linux-$(uname -m).tar.gz && \ - mkdir /cmake && \ - tar -xzf cmake.tar.gz -C /cmake --strip-components 1 && \ - rm cmake.tar.gz - # install build essentials RUN yum install -y \ perl \ @@ -95,6 +89,15 @@ RUN yum install -y \ ln -sf /usr/local/bin/make /usr/bin/make && \ cd .. && \ rm -Rf make* && \ + curl -o cmake.tar.gz -fsSL https://github.com/Kitware/CMake/releases/download/v4.1.2/cmake-4.1.2-linux-$(uname -m).tar.gz && \ + mkdir /cmake && \ + tar -xzf cmake.tar.gz -C /cmake --strip-components 1 && \ + rm cmake.tar.gz && \ + curl -fsSL -o patchelf.tar.gz https://github.com/NixOS/patchelf/releases/download/0.18.0/patchelf-0.18.0-$(uname -m).tar.gz && \ + mkdir -p /patchelf && \ + tar -xzf patchelf.tar.gz -C /patchelf --strip-components=1 && \ + cp /patchelf/bin/patchelf /usr/bin/ && \ + rm patchelf.tar.gz && \ if [ "$(uname -m)" = "aarch64" ]; then \ GO_ARCH="arm64" ; \ else \ From f7756717b56700f31cbc03127de333e39d5c7f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 21 Oct 2025 11:20:54 +0200 Subject: [PATCH 7/9] feat: allow creating strongly typed slices and maps from PHP values with type utilities (#1933) * feat: use generics in type functions for better type support * various improvements * better docs * update docs --- docs/extensions.md | 24 +++-- threadworker.go | 8 +- types.go | 260 ++++++++++++++++++++++++++++++++------------- types_test.go | 41 ++++--- 4 files changed, 233 insertions(+), 100 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 2c2253b9..dadaa6d1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -53,6 +53,7 @@ package example import "C" import ( "strings" + "unsafe" "github.com/dunglas/frankenphp" ) @@ -133,7 +134,10 @@ import ( // export_php:function process_data_ordered(array $input): array func process_data_ordered_map(arr *C.zval) unsafe.Pointer { // Convert PHP associative array to Go while keeping the order - associativeArray := frankenphp.GoAssociativeArray(unsafe.Pointer(arr)) + associativeArray, err := frankenphp.GoAssociativeArray[any](unsafe.Pointer(arr)) + if err != nil { + // handle error + } // loop over the entries in order for _, key := range associativeArray.Order { @@ -143,8 +147,8 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer { // return an ordered array // if 'Order' is not empty, only the key-value pairs in 'Order' will be respected - return frankenphp.PHPAssociativeArray(frankenphp.AssociativeArray{ - Map: map[string]any{ + return frankenphp.PHPAssociativeArray[string](frankenphp.AssociativeArray[string]{ + Map: map[string]string{ "key1": "value1", "key2": "value2", }, @@ -156,7 +160,10 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer { func process_data_unordered_map(arr *C.zval) unsafe.Pointer { // Convert PHP associative array to a Go map without keeping the order // ignoring the order will be more performant - goMap := frankenphp.GoMap(unsafe.Pointer(arr)) + goMap, err := frankenphp.GoMap[any](unsafe.Pointer(arr)) + if err != nil { + // handle error + } // loop over the entries in no specific order for key, value := range goMap { @@ -164,7 +171,7 @@ func process_data_unordered_map(arr *C.zval) unsafe.Pointer { } // return an unordered array - return frankenphp.PHPMap(map[string]any{ + return frankenphp.PHPMap(map[string]string { "key1": "value1", "key2": "value2", }) @@ -173,7 +180,10 @@ func process_data_unordered_map(arr *C.zval) unsafe.Pointer { // export_php:function process_data_packed(array $input): array func process_data_packed(arr *C.zval) unsafe.Pointer { // Convert PHP packed array to Go - goSlice := frankenphp.GoPackedArray(unsafe.Pointer(arr), false) + goSlice, err := frankenphp.GoPackedArray(unsafe.Pointer(arr), false) + if err != nil { + // handle error + } // loop over the slice in order for index, value := range goSlice { @@ -181,7 +191,7 @@ func process_data_packed(arr *C.zval) unsafe.Pointer { } // return a packed array - return frankenphp.PHPackedArray([]any{"value1", "value2", "value3"}) + return frankenphp.PHPPackedArray([]string{"value1", "value2", "value3"}) } ``` diff --git a/threadworker.go b/threadworker.go index fabcc7c2..fd9c2867 100644 --- a/threadworker.go +++ b/threadworker.go @@ -4,6 +4,7 @@ package frankenphp import "C" import ( "context" + "fmt" "log/slog" "path/filepath" "time" @@ -245,7 +246,12 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval thread := phpThreads[threadIndex] fc := thread.getRequestContext() if retval != nil { - fc.handlerReturn = GoValue(unsafe.Pointer(retval)) + r, err := GoValue[any](unsafe.Pointer(retval)) + if err != nil { + logger.Error(fmt.Sprintf("cannot convert return value: %s", err)) + } + + fc.handlerReturn = r } fc.closeContext() diff --git a/types.go b/types.go index aaa4dac3..4e4bbbbe 100644 --- a/types.go +++ b/types.go @@ -5,11 +5,17 @@ package frankenphp */ import "C" import ( + "errors" "fmt" + "reflect" "strconv" "unsafe" ) +type toZval interface { + toZval() *C.zval +} + // EXPERIMENTAL: GoString copies a zend_string to a Go string. func GoString(s unsafe.Pointer) string { if s == nil { @@ -39,37 +45,44 @@ func PHPString(s string, persistent bool) unsafe.Pointer { } // AssociativeArray represents a PHP array with ordered key-value pairs -type AssociativeArray struct { - Map map[string]any +type AssociativeArray[T any] struct { + Map map[string]T Order []string } +func (a AssociativeArray[T]) toZval() *C.zval { + return (*C.zval)(PHPAssociativeArray[T](a)) +} + // EXPERIMENTAL: GoAssociativeArray converts a zend_array to a Go AssociativeArray -func GoAssociativeArray(arr unsafe.Pointer) AssociativeArray { - entries, order := goArray(arr, true) - return AssociativeArray{entries, order} +func GoAssociativeArray[T any](arr unsafe.Pointer) (AssociativeArray[T], error) { + entries, order, err := goArray[T](arr, true) + + return AssociativeArray[T]{entries, order}, err } // EXPERIMENTAL: GoMap converts a zval having a zend_array value to an unordered Go map -func GoMap(arr unsafe.Pointer) map[string]any { - entries, _ := goArray(arr, false) - return entries +func GoMap[T any](arr unsafe.Pointer) (map[string]T, error) { + entries, _, err := goArray[T](arr, false) + + return entries, err } -func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) { +func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, error) { if arr == nil { - panic("received a nil pointer on array conversion") + return nil, nil, errors.New("received a nil pointer on array conversion") } zval := (*C.zval)(arr) - hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY)) - - if hashTable == nil { - panic("received a *zval that wasn't a HashTable on array conversion") + v, err := extractZvalValue(zval, C.IS_ARRAY) + if err != nil { + return nil, nil, fmt.Errorf("received a *zval that wasn't a HashTable on array conversion: %w", err) } + hashTable := (*C.HashTable)(v) + nNumUsed := hashTable.nNumUsed - entries := make(map[string]any, nNumUsed) + entries := make(map[string]T, nNumUsed) var order []string if ordered { order = make([]string, 0, nNumUsed) @@ -83,27 +96,42 @@ func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) { v := C.get_ht_packed_data(hashTable, i) if v != nil && C.zval_get_type(v) != C.IS_UNDEF { strIndex := strconv.Itoa(int(i)) - entries[strIndex] = goValue(v) + e, err := goValue[T](v) + if err != nil { + return nil, nil, err + } + + entries[strIndex] = e if ordered { order = append(order, strIndex) } } } - return entries, order + return entries, order, nil } + var zeroVal T + for i := C.uint32_t(0); i < nNumUsed; i++ { bucket := C.get_ht_bucket_data(hashTable, i) if bucket == nil || C.zval_get_type(&bucket.val) == C.IS_UNDEF { continue } - v := goValue(&bucket.val) + v, err := goValue[any](&bucket.val) + if err != nil { + return nil, nil, err + } if bucket.key != nil { keyStr := GoString(unsafe.Pointer(bucket.key)) - entries[keyStr] = v + if v == nil { + entries[keyStr] = zeroVal + } else { + entries[keyStr] = v.(T) + } + if ordered { order = append(order, keyStr) } @@ -113,64 +141,75 @@ func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) { // as fallback convert the bucket index to a string key strIndex := strconv.Itoa(int(bucket.h)) - entries[strIndex] = v + entries[strIndex] = v.(T) if ordered { order = append(order, strIndex) } } - return entries, order + return entries, order, nil } // EXPERIMENTAL: GoPackedArray converts a zval with a zend_array value to a Go slice -func GoPackedArray(arr unsafe.Pointer) []any { +func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) { if arr == nil { - panic("GoPackedArray received a nil pointer") + return nil, errors.New("GoPackedArray received a nil value") } zval := (*C.zval)(arr) - hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY)) - - if hashTable == nil { - panic("GoPackedArray received *zval that wasn't a HashTable") + v, err := extractZvalValue(zval, C.IS_ARRAY) + if err != nil { + return nil, fmt.Errorf("GoPackedArray received *zval that wasn't a HashTable: %w", err) } + hashTable := (*C.HashTable)(v) + nNumUsed := hashTable.nNumUsed - result := make([]any, 0, nNumUsed) + result := make([]T, 0, nNumUsed) if htIsPacked(hashTable) { for i := C.uint32_t(0); i < nNumUsed; i++ { v := C.get_ht_packed_data(hashTable, i) if v != nil && C.zval_get_type(v) != C.IS_UNDEF { - result = append(result, goValue(v)) + v, err := goValue[T](v) + if err != nil { + return nil, err + } + + result = append(result, v) } } - return result + return result, nil } // fallback if ht isn't packed - equivalent to array_values() for i := C.uint32_t(0); i < nNumUsed; i++ { bucket := C.get_ht_bucket_data(hashTable, i) if bucket != nil && C.zval_get_type(&bucket.val) != C.IS_UNDEF { - result = append(result, goValue(&bucket.val)) + v, err := goValue[T](&bucket.val) + if err != nil { + return nil, err + } + + result = append(result, v) } } - return result + return result, nil } // EXPERIMENTAL: PHPMap converts an unordered Go map to a PHP zend_array -func PHPMap(arr map[string]any) unsafe.Pointer { - return phpArray(arr, nil) +func PHPMap[T any](arr map[string]T) unsafe.Pointer { + return phpArray[T](arr, nil) } // EXPERIMENTAL: PHPAssociativeArray converts a Go AssociativeArray to a PHP zval with a zend_array value -func PHPAssociativeArray(arr AssociativeArray) unsafe.Pointer { - return phpArray(arr.Map, arr.Order) +func PHPAssociativeArray[T any](arr AssociativeArray[T]) unsafe.Pointer { + return phpArray[T](arr.Map, arr.Order) } -func phpArray(entries map[string]any, order []string) unsafe.Pointer { +func phpArray[T any](entries map[string]T, order []string) unsafe.Pointer { var zendArray *C.HashTable if len(order) != 0 { @@ -195,7 +234,7 @@ func phpArray(entries map[string]any, order []string) unsafe.Pointer { } // EXPERIMENTAL: PHPPackedArray converts a Go slice to a PHP zval with a zend_array value. -func PHPPackedArray(slice []any) unsafe.Pointer { +func PHPPackedArray[T any](slice []T) unsafe.Pointer { zendArray := createNewArray((uint32)(len(slice))) for _, val := range slice { zval := phpValue(val) @@ -209,54 +248,117 @@ func PHPPackedArray(slice []any) unsafe.Pointer { } // EXPERIMENTAL: GoValue converts a PHP zval to a Go value -func GoValue(zval unsafe.Pointer) any { - return goValue((*C.zval)(zval)) +// +// Zval having the null, bool, long, double, string and array types are currently supported. +// Arrays can curently only be converted to any[] and AssociativeArray[any]. +// Any other type will cause an error. +// More types may be supported in the future. +func GoValue[T any](zval unsafe.Pointer) (T, error) { + return goValue[T]((*C.zval)(zval)) } -func goValue(zval *C.zval) any { +func goValue[T any](zval *C.zval) (res T, err error) { + var ( + resAny any + resZero T + ) t := C.zval_get_type(zval) switch t { case C.IS_NULL: - return nil + resAny = any(nil) case C.IS_FALSE: - return false + resAny = any(false) case C.IS_TRUE: - return true + resAny = any(true) case C.IS_LONG: - longPtr := (*C.zend_long)(extractZvalValue(zval, C.IS_LONG)) - if longPtr != nil { - return int64(*longPtr) + v, err := extractZvalValue(zval, C.IS_LONG) + if err != nil { + return resZero, err } - return int64(0) + if v != nil { + resAny = any(int64(*(*C.zend_long)(v))) + + break + } + + resAny = any(int64(0)) case C.IS_DOUBLE: - doublePtr := (*C.double)(extractZvalValue(zval, C.IS_DOUBLE)) - if doublePtr != nil { - return float64(*doublePtr) + v, err := extractZvalValue(zval, C.IS_DOUBLE) + if err != nil { + return resZero, err } - return float64(0) + if v != nil { + resAny = any(float64(*(*C.double)(v))) + + break + } + + resAny = any(float64(0)) case C.IS_STRING: - str := (*C.zend_string)(extractZvalValue(zval, C.IS_STRING)) - if str == nil { - return "" + v, err := extractZvalValue(zval, C.IS_STRING) + if err != nil { + return resZero, err } - return GoString(unsafe.Pointer(str)) + if v == nil { + resAny = any("") + + break + } + + resAny = any(GoString(v)) case C.IS_ARRAY: - hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY)) - if hashTable != nil && htIsPacked(hashTable) { - return GoPackedArray(unsafe.Pointer(zval)) + v, err := extractZvalValue(zval, C.IS_ARRAY) + if err != nil { + return resZero, err } - return GoAssociativeArray(unsafe.Pointer(zval)) + hashTable := (*C.HashTable)(v) + if hashTable != nil && htIsPacked(hashTable) { + typ := reflect.TypeOf(res) + if typ == nil || typ.Kind() == reflect.Interface && typ.NumMethod() == 0 { + r, e := GoPackedArray[any](unsafe.Pointer(zval)) + if e != nil { + return resZero, e + } + + resAny = any(r) + + break + } + + return resZero, fmt.Errorf("cannot convert packed array to non-any Go type %s", typ.String()) + } + + a, err := GoAssociativeArray[T](unsafe.Pointer(zval)) + if err != nil { + return resZero, err + } + + resAny = any(a) default: - return nil + return resZero, fmt.Errorf("unsupported zval type %d", t) } + + if resAny == nil { + return resZero, nil + } + + if castRes, ok := resAny.(T); ok { + return castRes, nil + } + + return resZero, fmt.Errorf("cannot cast value of type %T to type %T", resAny, res) } // EXPERIMENTAL: PHPValue converts a Go any to a PHP zval +// +// nil, bool, int, int64, float64, string, []any, and map[string]any are currently supported. +// Any other type will cause a panic. +// More types may be supported in the future. func PHPValue(value any) unsafe.Pointer { return unsafe.Pointer(phpValue(value)) } @@ -264,6 +366,10 @@ func PHPValue(value any) unsafe.Pointer { func phpValue(value any) *C.zval { var zval C.zval + if toZvalObj, ok := value.(toZval); ok { + return toZvalObj.toZval() + } + switch v := value.(type) { case nil: C.__zval_null__(&zval) @@ -278,10 +384,8 @@ func phpValue(value any) *C.zval { case string: str := (*C.zend_string)(PHPString(v, false)) C.__zval_string__(&zval, str) - case AssociativeArray: - return (*C.zval)(PHPAssociativeArray(v)) case map[string]any: - return (*C.zval)(PHPAssociativeArray(AssociativeArray{Map: v})) + return (*C.zval)(PHPAssociativeArray[any](AssociativeArray[any]{Map: v})) case []any: return (*C.zval)(PHPPackedArray(v)) default: @@ -305,23 +409,29 @@ func htIsPacked(ht *C.HashTable) bool { } // extractZvalValue returns a pointer to the zval value cast to the expected type -func extractZvalValue(zval *C.zval, expectedType C.uint8_t) unsafe.Pointer { - if zval == nil || C.zval_get_type(zval) != expectedType { - return nil +func extractZvalValue(zval *C.zval, expectedType C.uint8_t) (unsafe.Pointer, error) { + if zval == nil { + if expectedType == C.IS_NULL { + return nil, nil + } + + return nil, fmt.Errorf("zval type mismatch: expected %d, got nil", expectedType) + } + + if zType := C.zval_get_type(zval); zType != expectedType { + return nil, fmt.Errorf("zval type mismatch: expected %d, got %d", expectedType, zType) } v := unsafe.Pointer(&zval.value[0]) switch expectedType { - case C.IS_LONG: - return v - case C.IS_DOUBLE: - return v + case C.IS_LONG, C.IS_DOUBLE: + return v, nil case C.IS_STRING: - return unsafe.Pointer(*(**C.zend_string)(v)) + return unsafe.Pointer(*(**C.zend_string)(v)), nil case C.IS_ARRAY: - return unsafe.Pointer(*(**C.zend_array)(v)) - default: - return nil + return unsafe.Pointer(*(**C.zend_array)(v)), nil } + + return nil, fmt.Errorf("unsupported zval type %d", expectedType) } diff --git a/types_test.go b/types_test.go index 9499301f..122fe930 100644 --- a/types_test.go +++ b/types_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // execute the function on a PHP thread directly @@ -36,12 +37,13 @@ func TestGoString(t *testing.T) { func TestPHPMap(t *testing.T) { testOnDummyPHPThread(t, func() { - originalMap := map[string]any{ + originalMap := map[string]string{ "foo1": "bar1", "foo2": "bar2", } - convertedMap := GoMap(PHPMap(originalMap)) + convertedMap, err := GoMap[string](PHPMap(originalMap)) + require.NoError(t, err) assert.Equal(t, originalMap, convertedMap, "associative array should be equal after conversion") }) @@ -49,15 +51,16 @@ func TestPHPMap(t *testing.T) { func TestOrderedPHPAssociativeArray(t *testing.T) { testOnDummyPHPThread(t, func() { - originalArray := AssociativeArray{ - Map: map[string]any{ + originalArray := AssociativeArray[string]{ + Map: map[string]string{ "foo1": "bar1", "foo2": "bar2", }, Order: []string{"foo2", "foo1"}, } - convertedArray := GoAssociativeArray(PHPAssociativeArray(originalArray)) + convertedArray, err := GoAssociativeArray[string](PHPAssociativeArray(originalArray)) + require.NoError(t, err) assert.Equal(t, originalArray, convertedArray, "associative array should be equal after conversion") }) @@ -65,9 +68,10 @@ func TestOrderedPHPAssociativeArray(t *testing.T) { func TestPHPPackedArray(t *testing.T) { testOnDummyPHPThread(t, func() { - originalSlice := []any{"bar1", "bar2"} + originalSlice := []string{"bar1", "bar2"} - convertedSlice := GoPackedArray(PHPPackedArray(originalSlice)) + convertedSlice, err := GoPackedArray[string](PHPPackedArray(originalSlice)) + require.NoError(t, err) assert.Equal(t, originalSlice, convertedSlice, "slice should be equal after conversion") }) @@ -75,13 +79,14 @@ func TestPHPPackedArray(t *testing.T) { func TestPHPPackedArrayToGoMap(t *testing.T) { testOnDummyPHPThread(t, func() { - originalSlice := []any{"bar1", "bar2"} - expectedMap := map[string]any{ + originalSlice := []string{"bar1", "bar2"} + expectedMap := map[string]string{ "0": "bar1", "1": "bar2", } - convertedMap := GoMap(PHPPackedArray(originalSlice)) + convertedMap, err := GoMap[string](PHPPackedArray(originalSlice)) + require.NoError(t, err) assert.Equal(t, expectedMap, convertedMap, "convert a packed to an associative array") }) @@ -89,16 +94,17 @@ func TestPHPPackedArrayToGoMap(t *testing.T) { func TestPHPAssociativeArrayToPacked(t *testing.T) { testOnDummyPHPThread(t, func() { - originalArray := AssociativeArray{ - Map: map[string]any{ + originalArray := AssociativeArray[string]{ + Map: map[string]string{ "foo1": "bar1", "foo2": "bar2", }, Order: []string{"foo1", "foo2"}, } - expectedSlice := []any{"bar1", "bar2"} + expectedSlice := []string{"bar1", "bar2"} - convertedSlice := GoPackedArray(PHPAssociativeArray(originalArray)) + convertedSlice, err := GoPackedArray[string](PHPAssociativeArray(originalArray)) + require.NoError(t, err) assert.Equal(t, expectedSlice, convertedSlice, "convert an associative array to a slice") }) @@ -109,18 +115,19 @@ func TestNestedMixedArray(t *testing.T) { originalArray := map[string]any{ "string": "value", "int": int64(123), - "float": float64(1.2), + "float": 1.2, "true": true, "false": false, "nil": nil, "packedArray": []any{"bar1", "bar2"}, - "associativeArray": AssociativeArray{ + "associativeArray": AssociativeArray[any]{ Map: map[string]any{"foo1": "bar1", "foo2": "bar2"}, Order: []string{"foo2", "foo1"}, }, } - convertedArray := GoMap(PHPMap(originalArray)) + convertedArray, err := GoMap[any](PHPMap(originalArray)) + require.NoError(t, err) assert.Equal(t, originalArray, convertedArray, "nested mixed array should be equal after conversion") }) From 7f646734953caa0754c9e27f5850768625baa101 Mon Sep 17 00:00:00 2001 From: Florent Drousset Date: Tue, 21 Oct 2025 17:50:00 +0200 Subject: [PATCH 8/9] Fix small typo (#1934) --- docs/fr/extensions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fr/extensions.md b/docs/fr/extensions.md index 1dacbde5..f840938c 100644 --- a/docs/fr/extensions.md +++ b/docs/fr/extensions.md @@ -75,7 +75,7 @@ Il y a deux choses importantes à noter ici : - Une directive `//export_php:function` définit la signature de la fonction en PHP. C'est ainsi que le générateur sait comment générer la fonction PHP avec les bons paramètres et le bon type de retour ; - La fonction doit retourner un `unsafe.Pointer`. FrankenPHP fournit une API pour vous aider avec le jonglage de types entre C et Go. -Alors que le premier point parle de lui-même, le second peut être plus difficile à appréhender. Plongeons plus profondément dans la jonglage de types dans la section suivante. +Alors que le premier point parle de lui-même, le second peut être plus difficile à appréhender. Plongeons plus profondément dans le jonglage de types dans la section suivante. ### Jonglage de Types From fb1f46808e7ccd03ba3c00560c5ce5f568eb49fb Mon Sep 17 00:00:00 2001 From: Michal Kleiner Date: Wed, 22 Oct 2025 20:14:36 +1300 Subject: [PATCH 9/9] fix: typo in method name in example usage (#1935) --- docs/mercure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mercure.md b/docs/mercure.md index ceebad20..0acb6a7d 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -64,7 +64,7 @@ To dispatch an update to connected subscribers, send an authenticated POST reque const JWT = 'eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.PXwpfIGng6KObfZlcOXvcnWCJOWTFLtswGI5DZuWSK4'; -$updateID = file_get_contents('https://localhost/.well-known/mercure', context: stream_context_create(['http' => [ +$updateID = file_put_contents('https://localhost/.well-known/mercure', context: stream_context_create(['http' => [ 'method' => 'POST', 'header' => "Content-type: application/x-www-form-urlencoded\r\nAuthorization: Bearer " . JWT, 'content' => http_build_query([