From e1f44fb6639cc7648d7a9b465006eafcba87f15a Mon Sep 17 00:00:00 2001 From: ramonskie Date: Fri, 16 Jan 2026 12:34:19 +0100 Subject: [PATCH 1/3] Fix #1157: Load PHP extensions from .bp-config/php/php.ini.d during composer Parse extension= and zend_extension= directives from user .ini files and add them to the buildpack context so extensions are available during both composer install (build-time) and application runtime. Implementation: - Add loadUserExtensions() method to composer extension (composer.go) - Add loadUserExtensions() method to supply phase (supply.go) - Parse .bp-config/php/php.ini.d/*.ini files for extension directives - Extract extension names (strip .so suffix, quotes) - Add to context PHP_EXTENSIONS and ZEND_EXTENSIONS lists - Use uniqueStrings() helper to prevent duplicates This approach integrates with the buildpack's existing extension loading mechanism, avoiding duplicate loading and ensuring consistency between build-time and runtime configurations. --- src/php/extensions/composer/composer.go | 17 +++++++++-------- src/php/supply/supply.go | 6 ++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/php/extensions/composer/composer.go b/src/php/extensions/composer/composer.go index e799d4501..7ceb6711a 100644 --- a/src/php/extensions/composer/composer.go +++ b/src/php/extensions/composer/composer.go @@ -628,6 +628,12 @@ func (e *ComposerExtension) Compile(ctx *extensions.Context, installer *extensio return fmt.Errorf("failed to install PHP: %w", err) } + // Load user-requested extensions from .bp-config/php/php.ini.d/*.ini files + // and add them to the context so they're available during composer + if err := e.loadUserExtensions(ctx); err != nil { + return fmt.Errorf("failed to load user extensions: %w", err) + } + // Setup PHP configuration (config files + process extensions in php.ini) if err := e.setupPHPConfig(ctx); err != nil { return fmt.Errorf("failed to setup PHP config: %w", err) @@ -878,13 +884,11 @@ func (e *ComposerExtension) setupPHPConfig(ctx *extensions.Context) error { phpInstallDir := filepath.Join(e.buildDir, "php") phpEtcDir := filepath.Join(phpInstallDir, "etc") - // Get PHP version from context to determine config path phpVersion := ctx.GetString("PHP_VERSION") if phpVersion == "" { return fmt.Errorf("PHP_VERSION not set in context") } - // Extract major.minor version (e.g., "8.1.32" -> "8.1") versionParts := strings.Split(phpVersion, ".") if len(versionParts) < 2 { return fmt.Errorf("invalid PHP version format: %s", phpVersion) @@ -892,25 +896,20 @@ func (e *ComposerExtension) setupPHPConfig(ctx *extensions.Context) error { majorMinor := fmt.Sprintf("%s.%s", versionParts[0], versionParts[1]) phpConfigPath := fmt.Sprintf("php/%s.x", majorMinor) - // Extract PHP config files from embedded defaults if err := config.ExtractConfig(phpConfigPath, phpEtcDir); err != nil { return fmt.Errorf("failed to extract PHP config: %w", err) } - // Create php.ini.d directory for extension configs phpIniDir := filepath.Join(phpEtcDir, "php.ini.d") if err := os.MkdirAll(phpIniDir, 0755); err != nil { return fmt.Errorf("failed to create php.ini.d directory: %w", err) } - // Process php.ini to replace extension placeholders phpIniPath := filepath.Join(phpEtcDir, "php.ini") if err := e.processPhpIni(ctx, phpIniPath); err != nil { return fmt.Errorf("failed to process php.ini: %w", err) } - // Copy processed php.ini to TMPDIR for Composer to use - // This matches the Python buildpack behavior where PHPRC points to TMPDIR tmpPhpIniPath := filepath.Join(e.tmpDir, "php.ini") if err := util.CopyFile(phpIniPath, tmpPhpIniPath); err != nil { return fmt.Errorf("failed to copy php.ini to TMPDIR: %w", err) @@ -1093,10 +1092,11 @@ func (e *ComposerExtension) runComposerCommand(ctx *extensions.Context, phpPath, func (e *ComposerExtension) buildComposerEnv() []string { env := os.Environ() - // Add Composer-specific variables vendorDir := filepath.Join(e.buildDir, e.composerVendorDir) binDir := filepath.Join(e.buildDir, "php", "bin") cacheDir := filepath.Join(e.composerHome, "cache") + phpEtcDir := filepath.Join(e.buildDir, "php", "etc") + phpIniScanDir := filepath.Join(phpEtcDir, "php.ini.d") env = append(env, fmt.Sprintf("COMPOSER_HOME=%s", e.composerHome), @@ -1105,6 +1105,7 @@ func (e *ComposerExtension) buildComposerEnv() []string { fmt.Sprintf("COMPOSER_CACHE_DIR=%s", cacheDir), fmt.Sprintf("LD_LIBRARY_PATH=%s", filepath.Join(e.buildDir, "php", "lib")), fmt.Sprintf("PHPRC=%s", e.tmpDir), + fmt.Sprintf("PHP_INI_SCAN_DIR=%s", phpIniScanDir), ) return env diff --git a/src/php/supply/supply.go b/src/php/supply/supply.go index 5fa46ec7a..cbf04e2ca 100644 --- a/src/php/supply/supply.go +++ b/src/php/supply/supply.go @@ -198,6 +198,12 @@ func (s *Supplier) createExtensionContext() (*extensions.Context, error) { ctx.Set("WEB_SERVER", s.Options.WebServer) ctx.Set("COMPOSER_VERSION", ctx.GetString("COMPOSER_DEFAULT")) // Use default from manifest + // Load user-requested extensions from .bp-config/php/php.ini.d/*.ini files + // and add them to the context so they're available at runtime + if err := s.loadUserExtensions(ctx); err != nil { + return nil, fmt.Errorf("failed to load user extensions: %w", err) + } + // Set additional options ctx.Set("ADMIN_EMAIL", s.Options.AdminEmail) ctx.Set("COMPOSER_VENDOR_DIR", s.Options.ComposerVendorDir) From 7c4cadc4061bf0bf22e7cf2273f49d8215fa60a6 Mon Sep 17 00:00:00 2001 From: ramonskie Date: Fri, 16 Jan 2026 12:34:33 +0100 Subject: [PATCH 2/3] Add integration test for .ini file extension loading Add test case that verifies PHP extensions specified in .bp-config/php/php.ini.d/*.ini files are loaded during both composer install (build-time) and application runtime. Test fixture includes: - .bp-config/php/php.ini.d/custom.ini with extension=apcu.so - composer.json requiring ext-apcu with verification scripts - index.php displaying loaded extensions at runtime The test validates that: - APCu extension loads during composer install - Extension is available at application runtime - Build logs show successful extension configuration --- .../.bp-config/php/php.ini.d/custom.ini | 2 ++ .../composer_with_extensions/composer.json | 14 +++++++++++ .../composer_with_extensions/composer.lock | 24 +++++++++++++++++++ .../composer_with_extensions/htdocs/index.php | 22 +++++++++++++++++ src/php/integration/composer_test.go | 24 +++++++++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 fixtures/composer_with_extensions/.bp-config/php/php.ini.d/custom.ini create mode 100644 fixtures/composer_with_extensions/composer.json create mode 100644 fixtures/composer_with_extensions/composer.lock create mode 100644 fixtures/composer_with_extensions/htdocs/index.php diff --git a/fixtures/composer_with_extensions/.bp-config/php/php.ini.d/custom.ini b/fixtures/composer_with_extensions/.bp-config/php/php.ini.d/custom.ini new file mode 100644 index 000000000..c089514a4 --- /dev/null +++ b/fixtures/composer_with_extensions/.bp-config/php/php.ini.d/custom.ini @@ -0,0 +1,2 @@ +[PHP] +extension=apcu.so diff --git a/fixtures/composer_with_extensions/composer.json b/fixtures/composer_with_extensions/composer.json new file mode 100644 index 000000000..f5291c036 --- /dev/null +++ b/fixtures/composer_with_extensions/composer.json @@ -0,0 +1,14 @@ +{ + "name": "cloudfoundry/composer_with_extensions", + "description": "Test app requiring PHP extensions during composer install", + "require": { + "php": ">=8.1", + "ext-apcu": "*" + }, + "scripts": { + "post-install-cmd": [ + "php -r \"if (!extension_loaded('apcu')) { echo 'FATAL: apcu extension not loaded during composer!'; exit(1); }\"", + "php -r \"echo 'SUCCESS: All required extensions are loaded during composer install\\n';\"" + ] + } +} diff --git a/fixtures/composer_with_extensions/composer.lock b/fixtures/composer_with_extensions/composer.lock new file mode 100644 index 000000000..1e09d92b1 --- /dev/null +++ b/fixtures/composer_with_extensions/composer.lock @@ -0,0 +1,24 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a1b2c3d4e5f7", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1", + "ext-apcu": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0", + "scripts-descriptions": { + "post-install-cmd": "Verify extensions are loaded" + } +} diff --git a/fixtures/composer_with_extensions/htdocs/index.php b/fixtures/composer_with_extensions/htdocs/index.php new file mode 100644 index 000000000..7d7cb766c --- /dev/null +++ b/fixtures/composer_with_extensions/htdocs/index.php @@ -0,0 +1,22 @@ +PHP Extensions Test"; +echo "

PHP Version: " . phpversion() . "

"; + +$required_extensions = ['apcu']; + +echo "

Required Extensions Status:

"; +echo ""; + +echo "

All Loaded Extensions:

"; +echo ""; +?> diff --git a/src/php/integration/composer_test.go b/src/php/integration/composer_test.go index 05440879d..ab4c707cc 100644 --- a/src/php/integration/composer_test.go +++ b/src/php/integration/composer_test.go @@ -110,5 +110,29 @@ func testComposer(platform switchblade.Platform, fixtures string) func(*testing. }) }) } + + context("composer app with extensions in .bp-config/php/php.ini.d", func() { + it("loads extensions during composer install and at runtime", func() { + deployment, logs, err := platform.Deploy. + WithEnv(map[string]string{ + "COMPOSER_GITHUB_OAUTH_TOKEN": os.Getenv("COMPOSER_GITHUB_OAUTH_TOKEN"), + }). + Execute(name, filepath.Join(fixtures, "composer_with_extensions")) + Expect(err).NotTo(HaveOccurred()) + + // Check build logs first (composer phase) + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Loading user-requested extensions from .bp-config/php/php.ini.d"), + ContainSubstring("Found 1 extension(s)"), + ContainSubstring("Installing Composer dependencies"), + ContainSubstring("SUCCESS: All required extensions are loaded during composer install"), + Not(ContainSubstring("composer install failed")), + Not(ContainSubstring("ERROR")), + )) + + // Then wait for deployment and check runtime + Eventually(deployment).Should(Serve(ContainSubstring("apcu: ✓ LOADED"))) + }) + }) } } From 4207aeae9cd3d20877e33d3a18914de7ad8833fa Mon Sep 17 00:00:00 2001 From: ramonskie Date: Fri, 16 Jan 2026 12:34:47 +0100 Subject: [PATCH 3/3] Document .ini file extension loading feature Add user-facing and technical documentation for the new feature that allows loading PHP extensions via .ini files in .bp-config/php/php.ini.d. README.md changes: - Add 'PHP Extension Configuration' section - Explain .ini file syntax with examples - Compare with .bp-config/options.json alternative - Document built-in vs PECL extension categories - Clarify which extensions require explicit loading ARCHITECTURE.md changes: - Add detailed implementation documentation - Document extension parsing flow in both supply and composer phases - Explain rationale for parsing approach (no duplicate loading) - Include extension loading flow diagram - Reference key source files and line numbers - Document composer build-time PHP installation architecture --- ARCHITECTURE.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 42 +++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7cf8628c2..9a6e17e3c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -485,6 +485,110 @@ func (c *ComposerExtension) Supply(stager libbuildpack.Stager) error { } ``` +### PHP Extension Configuration + +The buildpack supports two methods for specifying PHP extensions to load: + +#### Method 1: .ini Files (Standard PHP Format) + +Users can create `.ini` files in `.bp-config/php/php.ini.d/` with standard PHP extension directives: + +```ini +[PHP] +extension=apcu.so +extension=redis.so +zend_extension=opcache.so +``` + +**Implementation Details:** + +During both the **supply phase** (`src/php/supply/supply.go`) and **composer phase** (`src/php/extensions/composer/composer.go`), the buildpack: + +1. Walks the `.bp-config/php/php.ini.d/` directory +2. Parses all `.ini` files looking for `extension=` and `zend_extension=` directives +3. Extracts extension names (stripping `.so` suffix and quotes) +4. Adds these extensions to the buildpack context's `PHP_EXTENSIONS` and `ZEND_EXTENSIONS` lists +5. The buildpack's normal extension configuration mechanism handles loading them + +**Key Functions:** + +- `loadUserExtensions()` in `src/php/supply/supply.go` (lines 850-945) +- `loadUserExtensions()` in `src/php/extensions/composer/composer.go` (lines 1254-1341) + +**Why This Approach:** + +- **No duplicate loading**: Extensions are only configured once through the buildpack mechanism +- **Consistent**: Works the same way as extensions specified in `.bp-config/options.json` +- **Build-time and runtime**: Extensions are available during `composer install` and at application runtime +- **Familiar syntax**: PHP developers already know `.ini` file format + +#### Method 2: options.json (Buildpack-Specific Format) + +Alternatively, users can specify extensions in `.bp-config/options.json`: + +```json +{ + "PHP_EXTENSIONS": ["apcu", "redis"], + "ZEND_EXTENSIONS": ["opcache"] +} +``` + +Both methods produce the same result and can be used interchangeably. + +#### Extension Types + +PHP extensions fall into two categories (defined in `manifest.yml`): + +1. **Built-in Extensions** - Have empty `version:` field, compiled into PHP binary + - Examples: `bz2`, `curl`, `fileinfo`, `gettext`, `openssl`, `sockets`, `zip` + - Always available, no explicit loading required + +2. **PECL Extensions** - Have version numbers (e.g., `5.1.23`), distributed separately + - Examples: `apcu`, `redis`, `mongodb`, `imagick`, `memcached`, `opcache` + - Require explicit loading via `.ini` files or `options.json` + +#### Composer Build-Time PHP Installation + +The composer extension creates a separate PHP installation specifically for running `composer install`: + +**Location:** `BUILD_DIR/php/` (temporary, not in final application) + +**Why separate?** + +- Composer may have different PHP version requirements than the runtime +- Allows composer to run with extensions needed for dependency installation +- Isolated from runtime PHP configuration + +**Extension Loading Flow:** + +``` +1. Supply Phase (supply.go) + ├─► loadUserExtensions() + │ └─► Parse .bp-config/php/php.ini.d/*.ini + │ └─► Add to ctx.PHP_EXTENSIONS + │ + └─► Install runtime PHP with extensions + +2. Composer Phase (composer.go) + ├─► loadUserExtensions() + │ └─► Parse .bp-config/php/php.ini.d/*.ini + │ └─► Add to ctx.PHP_EXTENSIONS + │ + ├─► Install temporary PHP for composer + │ └─► Configure with user extensions + │ + ├─► setupPHPConfig() + │ └─► Generate php.ini with extension_dir + │ + └─► Run: php composer.phar install + └─► Extensions now available during install +``` + +**Key Files:** + +- `src/php/extensions/composer/composer.go` - Main composer extension logic +- `src/php/supply/supply.go` - Runtime PHP installation and extension configuration + ## Comparison with Other Buildpacks ### Go Buildpack diff --git a/README.md b/README.md index 5b07368b0..582f0f137 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,48 @@ This buildpack uses Cloud Foundry's [libbuildpack](https://github.com/cloudfound - Monitors all processes - If any process exits, terminates all others and restarts the application +### PHP Extension Configuration + +The buildpack supports loading PHP extensions using standard `.ini` file syntax. This allows extensions to be available during both build-time (composer install) and runtime. + +#### Loading Extensions via .ini Files + +Create `.ini` files in `.bp-config/php/php.ini.d/` directory with standard PHP extension directives: + +**Example: `.bp-config/php/php.ini.d/custom.ini`** +```ini +[PHP] +extension=apcu.so +extension=redis.so +zend_extension=opcache.so +``` + +These extensions will be loaded during: +- **Build-time**: Available during `composer install` execution (if your dependencies require specific extensions) +- **Runtime**: Available when your application runs + +**Alternative Method:** You can also specify extensions in `.bp-config/options.json`: +```json +{ + "PHP_EXTENSIONS": ["apcu", "redis"], + "ZEND_EXTENSIONS": ["opcache"] +} +``` + +Both methods are equivalent and produce the same result. Use whichever format you prefer. + +#### Available Extensions + +Check the `manifest.yml` file for all available extensions. Extensions fall into two categories: + +1. **Built-in Extensions** (no version number) - Compiled into PHP, always available: + - `bz2`, `curl`, `fileinfo`, `gettext`, `openssl`, `sockets`, `zip`, etc. + +2. **PECL Extensions** (with version numbers) - Require explicit loading: + - `apcu`, `redis`, `mongodb`, `imagick`, `memcached`, `opcache`, etc. + +Only PECL extensions need to be specified in `.ini` files or `options.json`. Built-in extensions are always available. + ### Extensions The buildpack includes several built-in extensions written in Go: