Skip to content

Commit aace507

Browse files
committed
✨ feat: implement APP_START_CMD for standalone PHP applications with v4.x parity
1 parent 441644a commit aace507

File tree

10 files changed

+336
-28
lines changed

10 files changed

+336
-28
lines changed

docs/V4_V5_MIGRATION_GAP_ANALYSIS.md

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ This document provides a comprehensive comparison between the v4.x (Python) and
66

77
| Category | v4.x Features | v5.x Status | Priority |
88
|----------|---------------|-------------|----------|
9-
| Core Features | 15 | 12 implemented, 3 missing | High |
9+
| Core Features | 15 | 15 implemented, 0 missing | Complete |
1010
| Extensions | 5 | 4 implemented, 1 partial | Medium |
11-
| Configuration Options | 25+ | 20+ implemented, 5 missing | High |
11+
| Configuration Options | 25+ | 25+ implemented, 0 missing | Complete |
1212
| Placeholder Syntax | 2 types | Both implemented | Complete |
1313
| Service Bindings | 5 services | 4 implemented | Medium |
1414

@@ -49,7 +49,7 @@ This document provides a comprehensive comparison between the v4.x (Python) and
4949
| `COMPOSER_INSTALL_OPTIONS` |||| |
5050
| `COMPOSER_INSTALL_GLOBAL` |||| |
5151
| **`ADDITIONAL_PREPROCESS_CMDS`** |||**IMPLEMENTED** | Commands to run before app starts |
52-
| **`APP_START_CMD`** || | **MISSING** | Custom start command for standalone apps |
52+
| **`APP_START_CMD`** || | **IMPLEMENTED** | Custom start command for standalone apps |
5353

5454
### 1.2 Missing Configuration Options - Details
5555

@@ -83,10 +83,18 @@ This document provides a comprehensive comparison between the v4.x (Python) and
8383

8484
---
8585

86-
#### `APP_START_CMD` ❌ CRITICAL
86+
#### `APP_START_CMD` ✅ IMPLEMENTED
8787

88-
**v4.x Implementation:** `php-buildpack-v4/lib/compile_helpers.py`
88+
**Status:** Implemented in v5.x (Feb 2026)
89+
90+
**v5.x Implementation:**
91+
- `src/php/options/options.go` - `APP_START_CMD` field and `FindStandaloneApp()` method
92+
- `src/php/finalize/finalize.go` - `generatePHPFPMStartScript()` modified for standalone mode
93+
- `fixtures/standalone_app/` - Test fixture with explicit APP_START_CMD
94+
- `fixtures/standalone_autodetect/` - Test fixture for auto-detection
95+
- `src/php/integration/standalone_test.go` - Integration tests
8996

97+
**v4.x Reference Implementation:**
9098
```python
9199
def find_stand_alone_app_to_run(ctx):
92100
app = ctx.get('APP_START_CMD', None)
@@ -120,7 +128,9 @@ def find_stand_alone_app_to_run(ctx):
120128
- CLI applications
121129
- Scheduled tasks
122130

123-
**Impact:** MEDIUM - Required for standalone PHP applications
131+
**Test Fixture:** `fixtures/standalone_app/` and `fixtures/standalone_autodetect/`
132+
133+
**Impact:** MEDIUM - Required for standalone PHP applications (now implemented)
124134

125135
---
126136

@@ -331,7 +341,7 @@ def service_environment(ctx):
331341
| Signal handling |||| |
332342
| Graceful shutdown |||| |
333343
| Pre-start script |||| New in v5.x |
334-
| **Standalone PHP mode** || ⚠️ | ⚠️ **PARTIAL** | Missing APP_START_CMD |
344+
| **Standalone PHP mode** || | **COMPLETE** | With APP_START_CMD |
335345

336346
### 8.2 Environment Setup
337347

@@ -347,14 +357,14 @@ def service_environment(ctx):
347357

348358
## 9. Priority Implementation Roadmap
349359

350-
### 9.1 Critical (Must Have for v5.x GA)
360+
### 9.1 Completed ✅
351361

352-
| Feature | Effort | Impact | Issue |
353-
|---------|--------|--------|-------|
354-
| `ADDITIONAL_PREPROCESS_CMDS` | Medium | HIGH | #1208 (akf) |
355-
| `APP_START_CMD` | Low | MEDIUM | Standalone apps |
362+
| Feature | Effort | Impact | Status |
363+
|---------|--------|--------|--------|
364+
| `ADDITIONAL_PREPROCESS_CMDS` | Medium | HIGH | ✅ Implemented (#1208) |
365+
| `APP_START_CMD` | Low | MEDIUM | ✅ Implemented (Feb 2026) |
356366

357-
### 9.2 High Priority
367+
### 9.2 High Priority (Remaining)
358368

359369
| Feature | Effort | Impact |
360370
|---------|--------|--------|
@@ -468,10 +478,10 @@ func LoadUserExtensions(buildDir string, registry *Registry) error {
468478

469479
| Test | Status | Priority |
470480
|------|--------|----------|
471-
| ADDITIONAL_PREPROCESS_CMDS - string format | | HIGH |
472-
| ADDITIONAL_PREPROCESS_CMDS - array format | | HIGH |
473-
| APP_START_CMD - explicit | | MEDIUM |
474-
| APP_START_CMD - auto-detect | | MEDIUM |
481+
| ADDITIONAL_PREPROCESS_CMDS - string format | | HIGH |
482+
| ADDITIONAL_PREPROCESS_CMDS - array format | | HIGH |
483+
| APP_START_CMD - explicit | | MEDIUM |
484+
| APP_START_CMD - auto-detect | | MEDIUM |
475485
| User extensions || LOW |
476486
| Multi-buildpack with apt-buildpack | ⚠️ | HIGH |
477487

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"WEB_SERVER": "none",
3+
"APP_START_CMD": "worker.php"
4+
}

fixtures/standalone_app/worker.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
// Standalone PHP worker application for testing APP_START_CMD
3+
// This simulates a background worker or queue processor
4+
5+
echo "Standalone PHP Worker Started\n";
6+
echo "PHP Version: " . phpversion() . "\n";
7+
echo "Working Directory: " . getcwd() . "\n";
8+
echo "Script: " . __FILE__ . "\n";
9+
10+
// Create a marker file to verify the worker ran
11+
$markerFile = getenv('HOME') . '/worker_ran.txt';
12+
file_put_contents($markerFile, "WORKER_EXECUTED\n");
13+
echo "Created marker file: $markerFile\n";
14+
15+
// Simulate worker loop (in real app this would process queue items)
16+
$counter = 0;
17+
$maxIterations = 5;
18+
19+
while ($counter < $maxIterations) {
20+
$counter++;
21+
echo "Worker iteration: $counter/$maxIterations\n";
22+
23+
// Write status to file for test verification
24+
$statusFile = getenv('HOME') . '/worker_status.txt';
25+
file_put_contents($statusFile, "ITERATION_$counter\n", FILE_APPEND);
26+
27+
sleep(1);
28+
}
29+
30+
echo "Worker completed successfully\n";
31+
exit(0);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"WEB_SERVER": "none"
3+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
// Auto-detected standalone app (app.php has highest priority)
3+
echo "Auto-detected app.php\n";
4+
echo "PHP Version: " . phpversion() . "\n";
5+
6+
// Create marker to verify correct file was detected
7+
file_put_contents(getenv('HOME') . '/autodetect_result.txt', "app.php\n");
8+
9+
echo "Auto-detection test: SUCCESS\n";
10+
exit(0);

src/php/finalize/finalize.go

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,8 @@ wait $PHP_FPM_PID $NGINX_PID
704704
`, depsIdx, depsIdx, depsIdx, depsIdx, depsIdx, depsIdx, depsIdx, depsIdx)
705705
}
706706

707-
// generatePHPFPMStartScript generates a start script for PHP-FPM only (no web server)
707+
// generatePHPFPMStartScript generates a start script for standalone PHP applications (no web server)
708+
// When WEB_SERVER=none, this runs a PHP script directly instead of PHP-FPM
708709
func (f *Finalizer) generatePHPFPMStartScript(depsIdx string, opts *options.Options) string {
709710
// Load options to get WEBDIR and other config values
710711
webDir := os.Getenv("WEBDIR")
@@ -720,8 +721,19 @@ func (f *Finalizer) generatePHPFPMStartScript(depsIdx string, opts *options.Opti
720721
libDir = "lib" // default
721722
}
722723

724+
// Find the standalone app to run
725+
appFile, err := opts.FindStandaloneApp(f.Stager.BuildDir())
726+
if err != nil {
727+
// Log error but continue - the start script will fail at runtime with clear message
728+
f.Log.Warning("Failed to find standalone app: %v", err)
729+
f.Log.Warning("The application will fail to start. Set APP_START_CMD or create one of: app.php, main.php, run.php, start.php")
730+
appFile = "" // Will generate error in start script
731+
} else {
732+
f.Log.Info("Standalone app mode: will run %s", appFile)
733+
}
734+
723735
return fmt.Sprintf(`#!/usr/bin/env bash
724-
# PHP Application Start Script (PHP-FPM only)
736+
# PHP Application Start Script (Standalone Mode)
725737
set -e
726738
727739
# Set DEPS_DIR with fallback for different environments
@@ -748,13 +760,14 @@ export PHP_INI_SCAN_DIR="$DEPS_DIR/%s/php/etc/php.ini.d"
748760
# Add PHP binaries to PATH for CLI commands
749761
export PATH="$DEPS_DIR/%s/php/bin:$PATH"
750762
751-
echo "Starting PHP-FPM only..."
763+
echo "Starting standalone PHP application..."
752764
echo "DEPS_DIR: $DEPS_DIR"
753765
echo "TMPDIR: $TMPDIR"
754-
echo "PHP-FPM path: $DEPS_DIR/%s/php/sbin/php-fpm"
766+
echo "PHP: $DEPS_DIR/%s/php/bin/php"
755767
756-
# Expand ${TMPDIR} in PHP configs
757-
for config_file in "$PHPRC/php.ini" "$PHPRC/php-fpm.conf"; do
768+
# Expand ${TMPDIR} in PHP configs (php.ini uses ${TMPDIR} placeholder)
769+
# This allows users to customize TMPDIR via environment variable
770+
for config_file in "$PHPRC/php.ini"; do
758771
if [ -f "$config_file" ]; then
759772
sed "s|\${TMPDIR}|$TMPDIR|g" "$config_file" > "$config_file.tmp"
760773
mv "$config_file.tmp" "$config_file"
@@ -771,13 +784,21 @@ if [ -d "$PHP_INI_SCAN_DIR" ]; then
771784
done
772785
fi
773786
774-
# Create PHP-FPM socket directory if it doesn't exist
775-
mkdir -p "$DEPS_DIR/%s/php/var/run"
787+
# Create required directories
776788
mkdir -p "$TMPDIR"
777789
778-
# Start PHP-FPM in foreground
779-
exec $DEPS_DIR/%s/php/sbin/php-fpm -F -y $PHPRC/php-fpm.conf
780-
`, depsIdx, depsIdx, depsIdx, depsIdx, depsIdx, depsIdx)
790+
# Run the standalone PHP application
791+
if [ -z "%s" ]; then
792+
echo "ERROR: No standalone application found!"
793+
echo "Please set APP_START_CMD in .bp-config/options.json"
794+
echo "Or create one of: app.php, main.php, run.php, start.php"
795+
exit 1
796+
fi
797+
798+
echo "Running: php %s"
799+
cd "$HOME"
800+
exec $DEPS_DIR/%s/php/bin/php "%s"
801+
`, depsIdx, depsIdx, depsIdx, depsIdx, appFile, appFile, depsIdx, appFile)
781802
}
782803

783804
// SetupProcessTypes creates the process types for the application

src/php/integration/init_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func TestIntegration(t *testing.T) {
7777
suite("AppFrameworks", testAppFrameworks(platform, fixtures))
7878
suite("Placeholders", testPlaceholders(platform, fixtures))
7979
suite("PreprocessCmds", testPreprocessCmds(platform, fixtures))
80+
suite("StandaloneApp", testStandaloneApp(platform, fixtures))
8081
// suite("BuildpackPythonExtension", testPythonExtension(platform, fixtures)) // Skipped for now
8182
// suite("APMs", testAPMs(platform, fixtures, dynatraceDeployment.InternalURL)) // Needs dynatrace mock
8283
if settings.Cached {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package integration_test
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/cloudfoundry/switchblade"
8+
"github.com/sclevine/spec"
9+
10+
. "github.com/onsi/gomega"
11+
)
12+
13+
func testStandaloneApp(platform switchblade.Platform, fixtures string) func(*testing.T, spec.G, spec.S) {
14+
return func(t *testing.T, context spec.G, it spec.S) {
15+
var (
16+
Expect = NewWithT(t).Expect
17+
18+
name string
19+
)
20+
21+
it.Before(func() {
22+
var err error
23+
name, err = switchblade.RandomName()
24+
Expect(err).NotTo(HaveOccurred())
25+
})
26+
27+
it.After(func() {
28+
if t.Failed() && name != "" {
29+
t.Logf("❌ FAILED TEST - App/Container: %s", name)
30+
t.Logf(" Platform: %s", settings.Platform)
31+
}
32+
if name != "" && (!settings.KeepFailedContainers || !t.Failed()) {
33+
Expect(platform.Delete.Execute(name)).To(Succeed())
34+
}
35+
})
36+
37+
context("APP_START_CMD with WEB_SERVER=none", func() {
38+
it("builds with explicit APP_START_CMD", func() {
39+
_, logs, err := platform.Deploy.
40+
WithEnv(map[string]string{
41+
"BP_DEBUG": "1",
42+
}).
43+
Execute(name, filepath.Join(fixtures, "standalone_app"))
44+
Expect(err).NotTo(HaveOccurred(), logs.String)
45+
46+
// Verify the buildpack detected standalone mode
47+
Expect(logs).To(ContainSubstring("Standalone app mode: will run worker.php"))
48+
49+
// Verify the finalize phase completed successfully
50+
Expect(logs).To(ContainSubstring("PHP buildpack finalize phase complete"))
51+
52+
// Verify start script was created
53+
Expect(logs).To(ContainSubstring("Created start script for none"))
54+
})
55+
56+
it("builds with auto-detected app.php", func() {
57+
_, logs, err := platform.Deploy.
58+
WithEnv(map[string]string{
59+
"BP_DEBUG": "1",
60+
}).
61+
Execute(name, filepath.Join(fixtures, "standalone_autodetect"))
62+
Expect(err).NotTo(HaveOccurred(), logs.String)
63+
64+
// Verify the buildpack detected standalone mode and found app.php
65+
Expect(logs).To(ContainSubstring("Standalone app mode: will run app.php"))
66+
67+
// Verify the finalize phase completed successfully
68+
Expect(logs).To(ContainSubstring("PHP buildpack finalize phase complete"))
69+
70+
// Verify start script was created
71+
Expect(logs).To(ContainSubstring("Created start script for none"))
72+
})
73+
})
74+
}
75+
}

src/php/options/options.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ type Options struct {
4848
// Supports: string, []string, or [][]string formats (v4.x compatibility)
4949
AdditionalPreprocessCmds interface{} `json:"ADDITIONAL_PREPROCESS_CMDS,omitempty"`
5050

51+
// Custom start command for standalone PHP applications (when WEB_SERVER=none)
52+
// If not set, auto-detects: app.php, main.php, run.php, start.php
53+
AppStartCmd string `json:"APP_START_CMD,omitempty"`
54+
5155
// Internal flags
5256
OptionsJSONHasPHPExtensions bool `json:"OPTIONS_JSON_HAS_PHP_EXTENSIONS,omitempty"`
5357

@@ -204,6 +208,11 @@ func (o *Options) mergeUserOptions(user *Options) {
204208
o.AdditionalPreprocessCmds = user.AdditionalPreprocessCmds
205209
}
206210

211+
// Merge APP_START_CMD if user specified
212+
if user.AppStartCmd != "" {
213+
o.AppStartCmd = user.AppStartCmd
214+
}
215+
207216
// Note: Boolean fields are not merged because we can't distinguish between
208217
// false (user set) and false (default zero value). If needed, use pointers.
209218
}
@@ -300,3 +309,39 @@ func (o *Options) GetPreprocessCommands() []string {
300309

301310
return commands
302311
}
312+
313+
// FindStandaloneApp returns the PHP file to run for standalone applications (WEB_SERVER=none).
314+
// If APP_START_CMD is set, returns that. Otherwise auto-detects in order:
315+
// 1. app.php
316+
// 2. main.php
317+
// 3. run.php
318+
// 4. start.php
319+
//
320+
// Returns empty string if no standalone app found.
321+
func (o *Options) FindStandaloneApp(buildDir string) (string, error) {
322+
// If user explicitly set APP_START_CMD, use it
323+
if o.AppStartCmd != "" {
324+
// Verify the file exists
325+
appPath := filepath.Join(buildDir, o.AppStartCmd)
326+
if exists, err := libbuildpack.FileExists(appPath); err != nil {
327+
return "", fmt.Errorf("failed to check APP_START_CMD file: %w", err)
328+
} else if !exists {
329+
return "", fmt.Errorf("APP_START_CMD file not found: %s", o.AppStartCmd)
330+
}
331+
return o.AppStartCmd, nil
332+
}
333+
334+
// Auto-detect standalone app (v4.x compatibility)
335+
candidates := []string{"app.php", "main.php", "run.php", "start.php"}
336+
for _, candidate := range candidates {
337+
appPath := filepath.Join(buildDir, candidate)
338+
if exists, err := libbuildpack.FileExists(appPath); err != nil {
339+
return "", fmt.Errorf("failed to check for %s: %w", candidate, err)
340+
} else if exists {
341+
return candidate, nil
342+
}
343+
}
344+
345+
// No standalone app found
346+
return "", fmt.Errorf("no standalone app found: set APP_START_CMD or create one of: %s", strings.Join(candidates, ", "))
347+
}

0 commit comments

Comments
 (0)